mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function CoCQueuePage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="CoC / CoP queue"
|
||||
description="Certificate of Competency review is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoCQueuePage;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function CoCReviewPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="CoC / CoP review"
|
||||
description="Certificate of Competency review is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoCReviewPage;
|
||||
@@ -5,6 +5,12 @@ import type {
|
||||
CreateExamPayload,
|
||||
UpdateExamPayload,
|
||||
AssignQuestionsPayload,
|
||||
RandomQuestionsPayload,
|
||||
ExamRegistration,
|
||||
RecordAttendancePayload,
|
||||
ExamIncident,
|
||||
CreateIncidentPayload,
|
||||
ResolveIncidentPayload,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -41,6 +47,49 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Server-side draw from the approved bank (US-EXAM-005). */
|
||||
selectRandomQuestions: builder.mutation<Exam, RandomQuestionsPayload>({
|
||||
query: ({ examId, count }) => ({
|
||||
url: `/exams/${examId}/questions/random`,
|
||||
method: 'POST',
|
||||
body: { count },
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Candidates and attendance (US-EXAM-007/009) ---------------------
|
||||
getExamRegistrations: builder.query<ExamRegistration[], string>({
|
||||
query: (examId) => `/exams/${examId}/registrations`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
recordAttendance: builder.mutation<ExamRegistration, RecordAttendancePayload>({
|
||||
query: ({ registrationId, ...body }) => ({
|
||||
url: `/exams/registrations/${registrationId}/attendance`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Session incidents (US-EXAM-010) ---------------------------------
|
||||
getExamIncidents: builder.query<ExamIncident[], string>({
|
||||
query: (examId) => `/exams/${examId}/incidents`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
recordIncident: builder.mutation<ExamIncident, CreateIncidentPayload>({
|
||||
query: ({ examId, ...body }) => ({
|
||||
url: `/exams/${examId}/incidents`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
resolveIncident: builder.mutation<ExamIncident, ResolveIncidentPayload>({
|
||||
query: ({ incidentId, ...body }) => ({
|
||||
url: `/exams/incidents/${incidentId}/resolve`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -52,4 +101,10 @@ export const {
|
||||
useUpdateExamMutation,
|
||||
useDeleteExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
useSelectRandomQuestionsMutation,
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
useGetExamIncidentsQuery,
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
} = examApi;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconUserCheck } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordAttendanceMutation,
|
||||
} from '../api/exam-api';
|
||||
import type { AttendanceStatus, ExamRegistration } from '../types/exam';
|
||||
|
||||
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
REGISTERED: 'gray',
|
||||
PRESENT: 'teal',
|
||||
LATE: 'yellow',
|
||||
ABSENT: 'red',
|
||||
WITHDRAWN: 'orange',
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
/** Rulings that end the sitting, and so must be explained (US-EXAM-009). */
|
||||
const NEEDS_REMARK: AttendanceStatus[] = ['WITHDRAWN', 'DISQUALIFIED'];
|
||||
|
||||
const OPTIONS: AttendanceStatus[] = [
|
||||
'PRESENT',
|
||||
'LATE',
|
||||
'ABSENT',
|
||||
'WITHDRAWN',
|
||||
'DISQUALIFIED',
|
||||
];
|
||||
|
||||
/**
|
||||
* The invigilator's register for one session (US-EXAM-009).
|
||||
*
|
||||
* Marking a paper later depends on what is recorded here: an absent,
|
||||
* withdrawn or disqualified candidate has no result to enter.
|
||||
*/
|
||||
export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: registrations, isError } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
|
||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
const candidateName = (registration: ExamRegistration) =>
|
||||
[
|
||||
registration.profile?.firstName,
|
||||
registration.profile?.middleName,
|
||||
registration.profile?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || registration.profileId.slice(0, 8);
|
||||
|
||||
const save = async () => {
|
||||
if (!target) return;
|
||||
if (NEEDS_REMARK.includes(status) && !remark.trim()) {
|
||||
notify.error(t('exam.candidates.remarkRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordAttendance({
|
||||
registrationId: target.id,
|
||||
status,
|
||||
remark: remark.trim() || undefined,
|
||||
}).unwrap();
|
||||
notify.success(t('exam.candidates.recorded'));
|
||||
setTarget(null);
|
||||
setRemark('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.incidents.error')));
|
||||
}
|
||||
};
|
||||
|
||||
// Officers without the invigilation permission simply do not see the
|
||||
// register; the endpoint refuses them and there is nothing to show.
|
||||
if (isError) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">
|
||||
{t('exam.candidates.section')}
|
||||
</Title>
|
||||
{(registrations ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t('exam.candidates.none')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.candidates.admission')}</Table.Th>
|
||||
<Table.Th>{t('exam.candidates.name')}</Table.Th>
|
||||
<Table.Th>{t('exam.candidates.attempt')}</Table.Th>
|
||||
<Table.Th>{t('exam.candidates.attendance')}</Table.Th>
|
||||
<Table.Th>{t('exam.candidates.remark')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(registrations ?? []).map((registration) => (
|
||||
<Table.Tr key={registration.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{registration.admissionNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{candidateName(registration)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
>
|
||||
{registration.kind === 'RETAKE'
|
||||
? t('exam.candidates.retake', { n: registration.attemptNumber })
|
||||
: t('exam.candidates.firstSitting')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.attendance.${registration.attendanceStatus}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
|
||||
{registration.attendanceRemark ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconUserCheck size={12} />}
|
||||
onClick={() => {
|
||||
setTarget(registration);
|
||||
setStatus(
|
||||
registration.attendanceStatus === 'REGISTERED'
|
||||
? 'PRESENT'
|
||||
: registration.attendanceStatus,
|
||||
);
|
||||
setRemark(registration.attendanceRemark ?? '');
|
||||
}}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(target)}
|
||||
onClose={() => setTarget(null)}
|
||||
title={t('exam.candidates.attendance')}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" fw={500}>
|
||||
{target ? candidateName(target) : ''} · {target?.admissionNumber}
|
||||
</Text>
|
||||
<Select
|
||||
label={t('exam.candidates.attendance')}
|
||||
data={OPTIONS.map((value) => ({
|
||||
value,
|
||||
label: t(`exam.attendance.${value}`),
|
||||
}))}
|
||||
value={status}
|
||||
onChange={(value) => setStatus((value as AttendanceStatus) ?? 'PRESENT')}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('exam.candidates.remark')}
|
||||
minRows={2}
|
||||
autosize
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.currentTarget.value)}
|
||||
required={NEEDS_REMARK.includes(status)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setTarget(null)}>
|
||||
{t('exam.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isLoading} onClick={save}>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamIncidentsQuery,
|
||||
useGetExamRegistrationsQuery,
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
} from '../api/exam-api';
|
||||
import type {
|
||||
ExamIncident,
|
||||
ExamIncidentStatus,
|
||||
ExamIncidentType,
|
||||
} from '../types/exam';
|
||||
|
||||
const TYPES: ExamIncidentType[] = [
|
||||
'MISCONDUCT',
|
||||
'TECHNICAL_FAILURE',
|
||||
'MEDICAL',
|
||||
'ADMINISTRATIVE',
|
||||
'OTHER',
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
OPEN: 'red',
|
||||
UNDER_REVIEW: 'yellow',
|
||||
RESOLVED: 'teal',
|
||||
DISMISSED: 'gray',
|
||||
};
|
||||
|
||||
/**
|
||||
* The session incident log (US-EXAM-010). Invigilators file entries during the
|
||||
* sitting; a supervisor closes each one with a written ruling that stays on
|
||||
* the record beside the original report.
|
||||
*/
|
||||
export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: incidents, isError } = useGetExamIncidentsQuery(examId);
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
|
||||
const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation();
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [type, setType] = useState<ExamIncidentType>('MISCONDUCT');
|
||||
const [registrationId, setRegistrationId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const [resolveTarget, setResolveTarget] = useState<ExamIncident | null>(null);
|
||||
const [outcome, setOutcome] = useState<'RESOLVED' | 'DISMISSED'>('RESOLVED');
|
||||
const [resolution, setResolution] = useState('');
|
||||
|
||||
const candidateOptions = (registrations ?? []).map((registration) => ({
|
||||
value: registration.id,
|
||||
label: `${registration.admissionNumber} — ${[
|
||||
registration.profile?.firstName,
|
||||
registration.profile?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}`,
|
||||
}));
|
||||
|
||||
const file = async () => {
|
||||
if (!description.trim()) {
|
||||
notify.error(t('exam.incidents.description'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordIncident({
|
||||
examId,
|
||||
type,
|
||||
registrationId: registrationId ?? undefined,
|
||||
description: description.trim(),
|
||||
}).unwrap();
|
||||
notify.success(t('exam.incidents.filed'));
|
||||
setAddOpen(false);
|
||||
setDescription('');
|
||||
setRegistrationId(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.incidents.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
if (!resolveTarget || !resolution.trim()) {
|
||||
notify.error(t('exam.incidents.resolution'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resolveIncident({
|
||||
incidentId: resolveTarget.id,
|
||||
outcome,
|
||||
resolution: resolution.trim(),
|
||||
}).unwrap();
|
||||
notify.success(t('exam.incidents.resolved'));
|
||||
setResolveTarget(null);
|
||||
setResolution('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('exam.incidents.error')));
|
||||
}
|
||||
};
|
||||
|
||||
// Hidden entirely from officers without the incident permission — the
|
||||
// endpoint refuses them, so there is nothing to render.
|
||||
if (isError) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={5}>{t('exam.incidents.section')}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
{t('exam.incidents.add')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{(incidents ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t('exam.incidents.none')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.incidents.type')}</Table.Th>
|
||||
<Table.Th>{t('exam.incidents.candidate')}</Table.Th>
|
||||
<Table.Th>{t('exam.incidents.description')}</Table.Th>
|
||||
<Table.Th>{t('exam.incidents.occurred')}</Table.Th>
|
||||
<Table.Th>{t('exam.incidents.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(incidents ?? []).map((incident) => (
|
||||
<Table.Tr key={incident.id}>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t(`exam.incidentType.${incident.type}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">
|
||||
{incident.registration?.admissionNumber ??
|
||||
t('exam.incidents.wholeRoom')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" maw={260} lineClamp={2}>
|
||||
{incident.description}
|
||||
</Text>
|
||||
{incident.resolution && (
|
||||
<Text fz="xs" c="dimmed" maw={260} lineClamp={2}>
|
||||
⤷ {incident.resolution}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[incident.status] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.incidentStatus.${incident.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{(incident.status === 'OPEN' ||
|
||||
incident.status === 'UNDER_REVIEW') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
onClick={() => {
|
||||
setResolveTarget(incident);
|
||||
setOutcome('RESOLVED');
|
||||
setResolution('');
|
||||
}}
|
||||
>
|
||||
{t('exam.incidents.resolve')}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
title={t('exam.incidents.add')}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t('exam.incidents.type')}
|
||||
data={TYPES.map((value) => ({
|
||||
value,
|
||||
label: t(`exam.incidentType.${value}`),
|
||||
}))}
|
||||
value={type}
|
||||
onChange={(value) => setType((value as ExamIncidentType) ?? 'OTHER')}
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
label={t('exam.incidents.candidate')}
|
||||
placeholder={t('exam.incidents.wholeRoom')}
|
||||
data={candidateOptions}
|
||||
value={registrationId}
|
||||
onChange={setRegistrationId}
|
||||
size="sm"
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
<Textarea
|
||||
label={t('exam.incidents.description')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setAddOpen(false)}>
|
||||
{t('exam.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isFiling} onClick={file}>
|
||||
{t('exam.incidents.add')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(resolveTarget)}
|
||||
onClose={() => setResolveTarget(null)}
|
||||
title={t('exam.incidents.resolve')}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm">{resolveTarget?.description}</Text>
|
||||
<Select
|
||||
label={t('exam.incidents.outcome')}
|
||||
data={[
|
||||
{ value: 'RESOLVED', label: t('exam.incidentStatus.RESOLVED') },
|
||||
{ value: 'DISMISSED', label: t('exam.incidentStatus.DISMISSED') },
|
||||
]}
|
||||
value={outcome}
|
||||
onChange={(value) =>
|
||||
setOutcome((value as 'RESOLVED' | 'DISMISSED') ?? 'RESOLVED')
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('exam.incidents.resolution')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={resolution}
|
||||
onChange={(event) => setResolution(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setResolveTarget(null)}>
|
||||
{t('exam.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isResolving} onClick={close}>
|
||||
{t('exam.incidents.resolve')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -38,19 +38,22 @@ import {
|
||||
IconUser,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDetails
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler, ModalFooter } from "@ema-platform/ui";
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamQuery,
|
||||
useUpdateExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
} from "../api/exam-api";
|
||||
import { useGetQuestionsQuery } from "../../question/api/question-api";
|
||||
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
|
||||
import { QuestionAssigner } from "../components/QuestionAssigner";
|
||||
import { RecordResultModal } from "../../result/components/RecordResultModal";
|
||||
import type { ExamStatus, QuestionBrief, actionTypes } from "../types/exam";
|
||||
useSelectRandomQuestionsMutation,
|
||||
} from '../api/exam-api';
|
||||
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import { QuestionAssigner } from '../components/QuestionAssigner';
|
||||
import { RecordResultModal } from '../../result/components/RecordResultModal';
|
||||
import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel';
|
||||
import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
@@ -100,31 +103,27 @@ export function ExamDetailPage() {
|
||||
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
const [assignQuestions, { isLoading: isAssigning }] =
|
||||
useAssignQuestionsMutation();
|
||||
const {
|
||||
data: exam,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useGetExamQuery(id ?? "", { skip: !id });
|
||||
const [assignQuestions, { isLoading: isAssigning }] = useAssignQuestionsMutation();
|
||||
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
|
||||
|
||||
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
|
||||
const { data: qRes } = useGetQuestionsQuery();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const allQuestions = qRes?.items ?? [];
|
||||
const certifications = certRes?.items ?? [];
|
||||
const [whatAction, setWhatAction] = useState<actionTypes>();
|
||||
|
||||
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
|
||||
// must not offer drafts or retired questions either.
|
||||
const eligibleQuestions = useMemo(() => {
|
||||
if (!exam) return [];
|
||||
return allQuestions
|
||||
.filter(
|
||||
(q) =>
|
||||
q.certificationId === exam.certificationId && q.form === exam.form,
|
||||
q.certificationId === exam.certificationId &&
|
||||
q.form === exam.form &&
|
||||
q.status === 'APPROVED',
|
||||
)
|
||||
.map((q) => ({
|
||||
id: q.id,
|
||||
title: q.title,
|
||||
form: q.form,
|
||||
points: q.points,
|
||||
}));
|
||||
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allQuestions, exam?.certificationId, exam?.form]);
|
||||
|
||||
@@ -158,75 +157,40 @@ export function ExamDetailPage() {
|
||||
openAssign();
|
||||
};
|
||||
|
||||
const handleRandomSelect = () => {
|
||||
const assignedIds = new Set(draftQuestions.map((q) => q.id));
|
||||
const currentTotal = draftQuestions.reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
const cuttingPoint = Number(exam.cuttingPoint);
|
||||
const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id));
|
||||
|
||||
if (eligible.length === 0) {
|
||||
notify.error("No eligible questions available for random selection");
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPossible =
|
||||
currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
|
||||
if (maxPossible < cuttingPoint) {
|
||||
/**
|
||||
* The draw happens on the server (US-EXAM-005): it picks only approved
|
||||
* items for this subject and writes the paper in one call, so the pool is
|
||||
* never shipped to the browser and cannot be reshuffled until it flatters.
|
||||
*/
|
||||
const handleRandomSelect = async () => {
|
||||
try {
|
||||
const updated = await selectRandom({ examId: exam.id, count: randomCount }).unwrap();
|
||||
setDraftQuestions(updated.questions ?? []);
|
||||
notify.success(t('exam.randomSelected', { count: (updated.questions ?? []).length }));
|
||||
closeAssign();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('exam.randomError'));
|
||||
notify.error(
|
||||
`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`,
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const shuffled = [...eligible].sort(() => Math.random() - 0.5);
|
||||
const targetCount = Math.min(randomCount, shuffled.length);
|
||||
const picked = shuffled.slice(0, targetCount);
|
||||
let pickedTotal = picked.reduce((s, q) => s + Number(q.points), 0);
|
||||
|
||||
if (currentTotal + pickedTotal < cuttingPoint) {
|
||||
const remaining = shuffled.slice(targetCount);
|
||||
for (const q of remaining) {
|
||||
if (currentTotal + pickedTotal >= cuttingPoint) break;
|
||||
picked.push(q);
|
||||
pickedTotal += q.points;
|
||||
}
|
||||
}
|
||||
|
||||
const msg =
|
||||
picked.length > targetCount
|
||||
? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)`
|
||||
: `Randomly selected ${picked.length} questions`;
|
||||
|
||||
setDraftQuestions([...draftQuestions, ...picked]);
|
||||
notify.info(msg);
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
try {
|
||||
const questionIds = draftQuestions.map((q) => q.id);
|
||||
if (whatAction === "add") {
|
||||
await assignQuestions({
|
||||
examId: exam.id,
|
||||
questionIds,
|
||||
remark: undefined,
|
||||
}).unwrap();
|
||||
notify.success("Questions assigned");
|
||||
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
|
||||
notify.success('Questions assigned');
|
||||
closeAssign();
|
||||
} else if (whatAction === "remove") {
|
||||
// await removeQuestions({
|
||||
// examId: exam.id,
|
||||
// questionIds,
|
||||
// remark: undefined,
|
||||
// });
|
||||
// notify.success("Questions removed");
|
||||
// closeAssign();
|
||||
notify.warning("no action yet");
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Failed to assign questions');
|
||||
notify.error(
|
||||
key.startsWith('question_not_approved')
|
||||
? t('question.qc.onlyApprovedUsable')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handlePrint = async () => {
|
||||
@@ -470,11 +434,11 @@ export function ExamDetailPage() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<RecordResultModal
|
||||
exam={exam}
|
||||
opened={recordOpened}
|
||||
onClose={closeRecord}
|
||||
/>
|
||||
{/* Exam-day operations: who sat the paper, and what went wrong */}
|
||||
<ExamCandidatesPanel examId={exam.id} />
|
||||
<ExamIncidentsPanel examId={exam.id} />
|
||||
|
||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||
|
||||
{/* Question assignment modal */}
|
||||
<Modal
|
||||
@@ -505,24 +469,18 @@ export function ExamDetailPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.assigner.randomHint", {
|
||||
total: eligibleQuestions.length,
|
||||
pts: exam.cuttingPoint,
|
||||
})}
|
||||
</Text>
|
||||
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
|
||||
<Group gap="sm">
|
||||
<NumberInput
|
||||
placeholder={t("exam.assigner.selectCount")}
|
||||
value={randomCount}
|
||||
onChange={(v) => setRandomCount(Number(v))}
|
||||
min={1}
|
||||
max={eligibleQuestions.length}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Button size="xs" variant="light" onClick={handleRandomSelect}>
|
||||
{t("exam.randomSelect")}
|
||||
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
|
||||
{t('exam.randomSelect')}
|
||||
</Button>
|
||||
</Group>
|
||||
<QuestionAssigner
|
||||
|
||||
@@ -85,4 +85,83 @@ export interface AssignQuestionsPayload {
|
||||
questionIds: string[];
|
||||
remark?: LocalePair;
|
||||
}
|
||||
export type actionTypes = "add" | "remove";
|
||||
|
||||
export interface RandomQuestionsPayload {
|
||||
examId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** What the invigilator recorded on the day (US-EXAM-009). */
|
||||
export type AttendanceStatus =
|
||||
| 'REGISTERED'
|
||||
| 'PRESENT'
|
||||
| 'ABSENT'
|
||||
| 'LATE'
|
||||
| 'WITHDRAWN'
|
||||
| 'DISQUALIFIED';
|
||||
|
||||
export interface ExamRegistration {
|
||||
id: string;
|
||||
examId: string;
|
||||
profileId: string;
|
||||
admissionNumber: string;
|
||||
kind: 'NEW' | 'RETAKE';
|
||||
attemptNumber: number;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
attendanceRemark: string | null;
|
||||
attendanceRecordedAt: string | null;
|
||||
createdAt: string;
|
||||
profile?: {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
middleName: string | null;
|
||||
lastName: string | null;
|
||||
seafarerNumber: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export type ExamIncidentType =
|
||||
| 'MISCONDUCT'
|
||||
| 'TECHNICAL_FAILURE'
|
||||
| 'MEDICAL'
|
||||
| 'ADMINISTRATIVE'
|
||||
| 'OTHER';
|
||||
|
||||
export type ExamIncidentStatus =
|
||||
| 'OPEN'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'RESOLVED'
|
||||
| 'DISMISSED';
|
||||
|
||||
export interface ExamIncident {
|
||||
id: string;
|
||||
examId: string;
|
||||
registrationId: string | null;
|
||||
type: ExamIncidentType;
|
||||
occurredAt: string;
|
||||
description: string;
|
||||
status: ExamIncidentStatus;
|
||||
resolution: string | null;
|
||||
resolvedAt: string | null;
|
||||
registration?: ExamRegistration;
|
||||
}
|
||||
|
||||
export interface CreateIncidentPayload {
|
||||
examId: string;
|
||||
type: ExamIncidentType;
|
||||
registrationId?: string;
|
||||
description: string;
|
||||
occurredAt?: string;
|
||||
}
|
||||
|
||||
export interface ResolveIncidentPayload {
|
||||
incidentId: string;
|
||||
outcome: 'RESOLVED' | 'DISMISSED';
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconSearch, IconShieldCog } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useGetLicensesQuery,
|
||||
useReinstateLicenseMutation,
|
||||
useRevokeLicenseMutation,
|
||||
useSuspendLicenseMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { IssuedLicense } from '@ema-platform/api';
|
||||
|
||||
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
EXPIRED: 'yellow',
|
||||
SUSPENDED: 'orange',
|
||||
CANCELLED: 'red',
|
||||
SUPERSEDED: 'gray',
|
||||
};
|
||||
|
||||
type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
|
||||
|
||||
const ACTIONS: Record<
|
||||
LifecycleAction,
|
||||
{ label: string; color: string; confirm: string }
|
||||
> = {
|
||||
suspend: {
|
||||
label: 'Suspend',
|
||||
color: 'orange',
|
||||
confirm: 'Temporarily withdraws the right to trade. Reversible.',
|
||||
},
|
||||
revoke: {
|
||||
label: 'Revoke',
|
||||
color: 'red',
|
||||
confirm: 'Permanently cancels the licence. This cannot be undone.',
|
||||
},
|
||||
reinstate: {
|
||||
label: 'Reinstate',
|
||||
color: 'green',
|
||||
confirm: 'Restores a suspended licence to active.',
|
||||
},
|
||||
};
|
||||
|
||||
/** Which lifecycle actions make sense from each current status. */
|
||||
function actionsFor(license: IssuedLicense): LifecycleAction[] {
|
||||
switch (license.status) {
|
||||
case 'ACTIVE':
|
||||
case 'EXPIRED':
|
||||
return ['suspend', 'revoke'];
|
||||
case 'SUSPENDED':
|
||||
return ['reinstate', 'revoke'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function LifecycleModal({
|
||||
license,
|
||||
onClose,
|
||||
}: {
|
||||
license: IssuedLicense | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [action, setAction] = useState<LifecycleAction | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [suspend, { isLoading: suspending }] = useSuspendLicenseMutation();
|
||||
const [revoke, { isLoading: revoking }] = useRevokeLicenseMutation();
|
||||
const [reinstate, { isLoading: reinstating }] = useReinstateLicenseMutation();
|
||||
|
||||
const available = license ? actionsFor(license) : [];
|
||||
|
||||
const submit = async () => {
|
||||
if (!license || !action) return;
|
||||
const run =
|
||||
action === 'suspend' ? suspend : action === 'revoke' ? revoke : reinstate;
|
||||
try {
|
||||
await run({ id: license.id, reason }).unwrap();
|
||||
notify.success(`Licence ${ACTIONS[action].label.toLowerCase()}d`);
|
||||
onClose();
|
||||
setAction(null);
|
||||
setReason('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not update the licence'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(license)}
|
||||
onClose={onClose}
|
||||
title={`Licence ${license?.certificateNumber ?? ''}`}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{license?.companyName} — currently {license?.status}. The reason is
|
||||
recorded verbatim on the licence and in the audit trail, and the
|
||||
holder is able to see it.
|
||||
</Text>
|
||||
<Select
|
||||
label="Action"
|
||||
required
|
||||
data={available.map((a) => ({ value: a, label: ACTIONS[a].label }))}
|
||||
value={action}
|
||||
onChange={(value) => setAction(value as LifecycleAction | null)}
|
||||
/>
|
||||
{action && (
|
||||
<Text size="sm" c={ACTIONS[action].color}>
|
||||
{ACTIONS[action].confirm}
|
||||
</Text>
|
||||
)}
|
||||
<Textarea
|
||||
label="Reason"
|
||||
required
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={action ? ACTIONS[action].color : undefined}
|
||||
disabled={!action || reason.trim().length < 3}
|
||||
loading={suspending || revoking || reinstating}
|
||||
onClick={submit}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The licence register (US-LOG-017/018): every issued licence with its
|
||||
* standing, and the suspend/revoke/reinstate actions that were previously
|
||||
* API-only. Queue work stays in the licence queue; this is enforcement.
|
||||
*/
|
||||
export function LicenseRegisterPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const { data, isLoading } = useGetLicensesQuery(
|
||||
search.trim() ? { search: search.trim() } : undefined,
|
||||
);
|
||||
const [target, setTarget] = useState<IssuedLicense | null>(null);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Licence register</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Certificate № or company"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
{isLoading ? (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : items.length === 0 ? (
|
||||
<Center h={160}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{search ? 'No licences match that search.' : 'No licences issued yet.'}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Certificate №</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Holder</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Expires</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((license) => (
|
||||
<Table.Tr key={license.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" ff="monospace" fw={600}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{localized(license.licenseType?.name)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{license.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.issueDate?.slice(0, 10)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.expiryDate?.slice(0, 10)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={LICENSE_STATUS_COLORS[license.status] ?? 'gray'}
|
||||
>
|
||||
{license.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{actionsFor(license).length > 0 && (
|
||||
<Tooltip label="Suspend / revoke / reinstate">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => setTarget(license)}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<LifecycleModal license={target} onClose={() => setTarget(null)} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseRegisterPage;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -73,6 +74,30 @@ import { resolveActions, type ActionId, type ResolvedAction } from '../config/ac
|
||||
|
||||
type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
|
||||
|
||||
/**
|
||||
* Standard site-visit checklist (US-LOG-016 / US-VES-007). The inspection
|
||||
* entity has carried a checklist column since day one; this is the first UI
|
||||
* that fills it in.
|
||||
*/
|
||||
const INSPECTION_CHECKLIST_ITEMS = [
|
||||
{ key: 'office_premises', label: 'Office premises' },
|
||||
{ key: 'storage_facilities', label: 'Warehouse / storage facilities' },
|
||||
{ key: 'vehicles_equipment', label: 'Vehicles / equipment' },
|
||||
{ key: 'safety_compliance', label: 'Safety & regulatory compliance' },
|
||||
] as const;
|
||||
|
||||
function buildChecklist(
|
||||
outcomes: Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>,
|
||||
) {
|
||||
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
// Untouched rows default to PASS — the segmented control shows exactly
|
||||
// that, so what the officer saw is what gets recorded.
|
||||
outcome: outcomes[item.key] ?? 'PASS',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The officer's review workspace.
|
||||
*
|
||||
@@ -124,6 +149,9 @@ export function LicenseReviewPage() {
|
||||
const [inspectionDate, setInspectionDate] = useState('');
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [findings, setFindings] = useState('');
|
||||
const [checklist, setChecklist] = useState<
|
||||
Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>
|
||||
>({});
|
||||
|
||||
// Prefill from whatever is recorded, else the declared figure, so the officer
|
||||
// confirms a number rather than retyping it. Runs before the early return
|
||||
@@ -934,6 +962,36 @@ export function LicenseReviewPage() {
|
||||
title={t('review.inspectionResult', 'Inspection result')}
|
||||
>
|
||||
<Stack>
|
||||
{/* Structured per-area outcomes; persisted to the inspection's
|
||||
checklist column, which was previously never populated. */}
|
||||
<Stack gap={6}>
|
||||
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
|
||||
<Group key={item.key} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{item.label}</Text>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={checklist[item.key] ?? 'PASS'}
|
||||
onChange={(value) =>
|
||||
setChecklist((prev) => ({
|
||||
...prev,
|
||||
[item.key]: value as
|
||||
| 'PASS'
|
||||
| 'FAIL'
|
||||
| 'NEEDS_CORRECTION',
|
||||
}))
|
||||
}
|
||||
data={[
|
||||
{ value: 'PASS', label: t('review.checkPass', 'Pass') },
|
||||
{
|
||||
value: 'NEEDS_CORRECTION',
|
||||
label: t('review.checkFix', 'Fix'),
|
||||
},
|
||||
{ value: 'FAIL', label: t('review.checkFail', 'Fail') },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Textarea
|
||||
label={t('review.findings', 'Findings')}
|
||||
withAsterisk
|
||||
@@ -959,6 +1017,7 @@ export function LicenseReviewPage() {
|
||||
applicationId: id,
|
||||
result: 'PASSED',
|
||||
findings,
|
||||
checklist: buildChecklist(checklist),
|
||||
}).unwrap();
|
||||
setResultOpen(false);
|
||||
}, t('review.done.inspectionPassed', 'Inspection passed'))
|
||||
@@ -982,6 +1041,7 @@ export function LicenseReviewPage() {
|
||||
applicationId: id,
|
||||
result: 'FAILED',
|
||||
findings,
|
||||
checklist: buildChecklist(checklist),
|
||||
}).unwrap();
|
||||
setResultOpen(false);
|
||||
}, t('review.done.inspectionFailed', 'Inspection failed'))
|
||||
|
||||
@@ -1,18 +1,365 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconStethoscope,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetPendingMedicalQuery,
|
||||
useGetPendingSeaServiceQuery,
|
||||
useVerifyMedicalCertificateMutation,
|
||||
useVerifySeaServiceRecordMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type {
|
||||
MedicalCertificate,
|
||||
SeaServiceRecord,
|
||||
SeafarerProfileSummary,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
if (!profile) return '—';
|
||||
return (
|
||||
[profile.firstName, profile.middleName, profile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ') || '—'
|
||||
);
|
||||
}
|
||||
|
||||
/** Reject dialog — the remark is what the seafarer sees and must act on. */
|
||||
function RejectModal({
|
||||
title,
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading,
|
||||
}: {
|
||||
title: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (remark: string) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [remark, setRemark] = useState('');
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} centered>
|
||||
<Stack>
|
||||
<Textarea
|
||||
label="What must the seafarer fix?"
|
||||
required
|
||||
minRows={2}
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={remark.trim().length < 3}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onConfirm(remark.trim());
|
||||
setRemark('');
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
* The record-verification workspace (US-SSM-003/007): everything seafarers
|
||||
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
|
||||
* freezes a record — sea service starts counting toward sea time, a medical
|
||||
* certificate starts satisfying the submission gate.
|
||||
*/
|
||||
export function MedicalVerificationPage() {
|
||||
const { data: pendingMedical, isLoading: loadingMedical } =
|
||||
useGetPendingMedicalQuery();
|
||||
const { data: pendingSeaService, isLoading: loadingSeaService } =
|
||||
useGetPendingSeaServiceQuery();
|
||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||
useVerifyMedicalCertificateMutation();
|
||||
const [verifySeaService, { isLoading: rulingSeaService }] =
|
||||
useVerifySeaServiceRecordMutation();
|
||||
const [rejectMedical, setRejectMedical] = useState<MedicalCertificate | null>(
|
||||
null,
|
||||
);
|
||||
const [rejectSeaService, setRejectSeaService] =
|
||||
useState<SeaServiceRecord | null>(null);
|
||||
|
||||
const rule = async (
|
||||
run: () => Promise<unknown>,
|
||||
done: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
notify.success(done);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not record the ruling'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Medical verification"
|
||||
description="Medical certificate verification is not connected to the backend yet."
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Record verification
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Submitted sea-service records and medical certificates awaiting a
|
||||
ruling. Verified records are frozen; rejections return to the seafarer
|
||||
with your remark.
|
||||
</Text>
|
||||
|
||||
<Tabs defaultValue="medical" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
Medical ({pendingMedical?.length ?? 0})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
Sea Service ({pendingSeaService?.length ?? 0})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
{loadingMedical ? (
|
||||
<Center h={160}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (pendingMedical ?? []).length === 0 ? (
|
||||
<Center h={120}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing awaiting verification.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Seafarer</Table.Th>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Validity</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(pendingMedical ?? []).map((certificate) => (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(certificate.profile)}
|
||||
</Text>
|
||||
{certificate.profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{certificate.profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issuerName}
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issueDate} → {certificate.expiryDate}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{certificate.fitnessStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={rulingMedical}
|
||||
onClick={() =>
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: certificate.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Certificate verified',
|
||||
)
|
||||
}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => setRejectMedical(certificate)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
{loadingSeaService ? (
|
||||
<Center h={160}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (pendingSeaService ?? []).length === 0 ? (
|
||||
<Center h={120}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing awaiting verification.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Seafarer</Table.Th>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>Period</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(pendingSeaService ?? []).map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(record.profile)}
|
||||
</Text>
|
||||
{record.profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{record.profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>
|
||||
{record.engagementDate} → {record.dischargeDate}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={rulingSeaService}
|
||||
onClick={() =>
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: record.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Sea-service record verified',
|
||||
)
|
||||
}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => setRejectSeaService(record)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RejectModal
|
||||
title="Reject medical certificate"
|
||||
opened={Boolean(rejectMedical)}
|
||||
onClose={() => setRejectMedical(null)}
|
||||
loading={rulingMedical}
|
||||
onConfirm={(remark) => {
|
||||
if (!rejectMedical) return;
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: rejectMedical.id,
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Certificate rejected',
|
||||
);
|
||||
setRejectMedical(null);
|
||||
}}
|
||||
/>
|
||||
<RejectModal
|
||||
title="Reject sea-service record"
|
||||
opened={Boolean(rejectSeaService)}
|
||||
onClose={() => setRejectSeaService(null)}
|
||||
loading={rulingSeaService}
|
||||
onConfirm={(remark) => {
|
||||
if (!rejectSeaService) return;
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: rejectSeaService.id,
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Sea-service record rejected',
|
||||
);
|
||||
setRejectSeaService(null);
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ListResponse,
|
||||
CreateQuestionPayload,
|
||||
UpdateQuestionPayload,
|
||||
ReviewQuestionPayload,
|
||||
} from '../types/question';
|
||||
|
||||
const questionApi = baseApi.injectEndpoints({
|
||||
@@ -32,6 +33,20 @@ const questionApi = baseApi.injectEndpoints({
|
||||
query: (id) => ({ url: `/questions/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Hand a drafted or corrected item to the supervisor (US-EXAM-003). */
|
||||
submitQuestion: builder.mutation<Question, string>({
|
||||
query: (id) => ({ url: `/questions/${id}/submit`, method: 'POST' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** Approve, reject or retire a bank item (US-EXAM-003). */
|
||||
reviewQuestion: builder.mutation<Question, ReviewQuestionPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/questions/${id}/review`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -42,4 +57,6 @@ export const {
|
||||
useCreateQuestionMutation,
|
||||
useUpdateQuestionMutation,
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
} = questionApi;
|
||||
|
||||
@@ -14,19 +14,39 @@ import {
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconSend,
|
||||
IconGavel,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import {
|
||||
useGetQuestionsQuery,
|
||||
useCreateQuestionMutation,
|
||||
useUpdateQuestionMutation,
|
||||
useDeleteQuestionMutation,
|
||||
useSubmitQuestionMutation,
|
||||
useReviewQuestionMutation,
|
||||
} from '../api/question-api';
|
||||
import type { Question, QuestionForm } from '../types/question';
|
||||
import type { Question, QuestionForm, QuestionStatus } from '../types/question';
|
||||
|
||||
/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */
|
||||
const QC_COLOR: Record<QuestionStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
PENDING_APPROVAL: 'yellow',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
RETIRED: 'dark',
|
||||
};
|
||||
|
||||
function QuestionForm({
|
||||
editing,
|
||||
@@ -107,6 +127,8 @@ export function QuestionPage() {
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||
const [deleteQ] = useDeleteQuestionMutation();
|
||||
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
|
||||
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
|
||||
|
||||
const certifications = certRes?.items ?? [];
|
||||
const questions = data?.items ?? [];
|
||||
@@ -116,6 +138,9 @@ export function QuestionPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [certFilter, setCertFilter] = useState<string | null>(null);
|
||||
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
|
||||
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
|
||||
const [reviewRemark, setReviewRemark] = useState('');
|
||||
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
|
||||
@@ -141,8 +166,42 @@ export function QuestionPage() {
|
||||
notify.success(t('question.created'));
|
||||
}
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForApproval = async (question: Question) => {
|
||||
try {
|
||||
await submitQ(question.id).unwrap();
|
||||
notify.success(t('question.qc.submitted'));
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
|
||||
setReviewTarget(question);
|
||||
setReviewOutcome(outcome);
|
||||
setReviewRemark('');
|
||||
};
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!reviewTarget) return;
|
||||
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
|
||||
notify.error(t('question.qc.remarkRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await reviewQ({
|
||||
id: reviewTarget.id,
|
||||
outcome: reviewOutcome,
|
||||
remark: reviewRemark.trim() || undefined,
|
||||
}).unwrap();
|
||||
notify.success(t('question.qc.reviewed'));
|
||||
setReviewTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('question.qc.error')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,19 +298,125 @@ export function QuestionPage() {
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('question.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('question.columns.title')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.certification')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.form')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.points')}</Table.Th>
|
||||
<Table.Th>{t('question.qc.column')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((q) => (
|
||||
<Table.Tr key={q.id}>
|
||||
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[q.status] ?? 'gray'} title={q.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${q.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={isSubmittingReview}
|
||||
onClick={() => handleSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={() => openReview(q, 'APPROVED')}
|
||||
>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => openReview(q, 'REJECTED')}
|
||||
>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => openReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(reviewTarget)}
|
||||
onClose={() => setReviewTarget(null)}
|
||||
title={t('question.qc.reviewTitle')}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
|
||||
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
|
||||
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
|
||||
{t(`question.qc.${reviewOutcome}`)}
|
||||
</Badge>
|
||||
<Textarea
|
||||
label={t('question.qc.remark')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={reviewRemark}
|
||||
onChange={(e) => setReviewRemark(e.currentTarget.value)}
|
||||
required={reviewOutcome !== 'APPROVED'}
|
||||
/>
|
||||
</Card>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setReviewTarget(null)}>
|
||||
{t('question.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isReviewing} onClick={handleReview}>
|
||||
{t(`question.qc.${reviewOutcome === 'APPROVED' ? 'approve' : reviewOutcome === 'REJECTED' ? 'reject' : 'retire'}`)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
|
||||
@@ -8,6 +8,14 @@ export interface EstimatedTime {
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
/** Question bank quality control (US-EXAM-003). */
|
||||
export type QuestionStatus =
|
||||
| 'DRAFT'
|
||||
| 'PENDING_APPROVAL'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'RETIRED';
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
certificationId: string;
|
||||
@@ -18,10 +26,20 @@ export interface Question {
|
||||
time: EstimatedTime | null;
|
||||
points: number;
|
||||
isActive: boolean;
|
||||
status: QuestionStatus;
|
||||
reviewRemark: string | null;
|
||||
reviewedAt: string | null;
|
||||
submittedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReviewQuestionPayload {
|
||||
id: string;
|
||||
outcome: 'APPROVED' | 'REJECTED' | 'RETIRED';
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
total: number;
|
||||
items: T[];
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type {
|
||||
Result,
|
||||
ExamAppeal,
|
||||
ListResponse,
|
||||
CreateResultPayload,
|
||||
UpdateResultPayload,
|
||||
ModerateResultPayload,
|
||||
ResultDecisionPayload,
|
||||
DecideAppealPayload,
|
||||
} from '../types/result';
|
||||
|
||||
const resultApi = baseApi.injectEndpoints({
|
||||
@@ -32,6 +36,54 @@ const resultApi = baseApi.injectEndpoints({
|
||||
query: (id) => ({ url: `/results/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Quality control (US-EXAM-012/013/014) ---------------------------
|
||||
moderateResult: builder.mutation<Result, ModerateResultPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/${id}/moderate`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
approveResult: builder.mutation<Result, ResultDecisionPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/${id}/approve`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
returnResult: builder.mutation<Result, ResultDecisionPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/${id}/return`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
publishExamResults: builder.mutation<
|
||||
{ examId: string; published: number; skipped: number },
|
||||
string
|
||||
>({
|
||||
query: (examId) => ({
|
||||
url: `/results/exam/${examId}/publish`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Appeals (US-EXAM-016) -------------------------------------------
|
||||
getPendingAppeals: builder.query<ExamAppeal[], void>({
|
||||
query: () => '/results/appeals/pending',
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
decideAppeal: builder.mutation<ExamAppeal, DecideAppealPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/appeals/${id}/decide`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -43,4 +95,10 @@ export const {
|
||||
useCreateResultMutation,
|
||||
useUpdateResultMutation,
|
||||
useDeleteResultMutation,
|
||||
useModerateResultMutation,
|
||||
useApproveResultMutation,
|
||||
useReturnResultMutation,
|
||||
usePublishExamResultsMutation,
|
||||
useGetPendingAppealsQuery,
|
||||
useDecideAppealMutation,
|
||||
} = resultApi;
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useCreateResultMutation } from '../api/result-api';
|
||||
import { useGetExamRegistrationsQuery } from '../../exam/api/exam-api';
|
||||
import type { Exam } from '../../exam/types/exam';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
@@ -49,19 +50,29 @@ export function RecordResultModal({
|
||||
const [questionRemarks, setQuestionRemarks] = useState<Record<string, string>>({});
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
const { data: profilesRes } = useApiQuery<{ total: number; items: any[] }>({
|
||||
url: '/profiles',
|
||||
params: { q: 'w=type:=:SEAFARER' },
|
||||
// Only candidates who registered and actually sat the paper can be marked
|
||||
// (US-EXAM-009 feeding US-EXAM-011) — the API refuses anyone else, so the
|
||||
// picker is the session's own register rather than every seafarer profile.
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(exam.id, {
|
||||
skip: !opened,
|
||||
});
|
||||
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
|
||||
const [updateResult] = useUpdateResultMutation();
|
||||
|
||||
const seafarers = profilesRes?.items ?? [];
|
||||
const questions = exam.questions ?? [];
|
||||
|
||||
const seafarerOptions = seafarers.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: `${s.firstName} ${s.middleName ?? ''} ${s.lastName}`,
|
||||
const seafarerOptions = (registrations ?? [])
|
||||
.filter((registration) =>
|
||||
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
|
||||
)
|
||||
.map((registration) => ({
|
||||
value: registration.profileId,
|
||||
label: `${registration.admissionNumber} — ${[
|
||||
registration.profile?.firstName,
|
||||
registration.profile?.middleName,
|
||||
registration.profile?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}`,
|
||||
}));
|
||||
|
||||
const filteredOptions = seafarerSearch
|
||||
@@ -69,7 +80,20 @@ export function RecordResultModal({
|
||||
: seafarerOptions;
|
||||
|
||||
const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0);
|
||||
const passed = totalScore >= exam.cuttingPoint;
|
||||
const maxScore = questions.reduce((sum, q) => sum + (q.points ?? 0), 0);
|
||||
// The cutting point is read per the exam's configured evaluation method —
|
||||
// an AVERAGE or PERCENTAGE exam must not be graded as a raw sum.
|
||||
const effectiveScore =
|
||||
exam.evaluationMethod === 'AVERAGE'
|
||||
? questions.length > 0
|
||||
? totalScore / questions.length
|
||||
: 0
|
||||
: exam.evaluationMethod === 'PERCENTAGE'
|
||||
? maxScore > 0
|
||||
? (totalScore / maxScore) * 100
|
||||
: 0
|
||||
: totalScore;
|
||||
const passed = effectiveScore >= exam.cuttingPoint;
|
||||
|
||||
const handleScoreChange = (questionId: string, value: number) => {
|
||||
setScores((prev) => ({ ...prev, [questionId]: value }));
|
||||
@@ -90,16 +114,16 @@ export function RecordResultModal({
|
||||
score: scores[q.id] ?? 0,
|
||||
remark: questionRemarks[q.id] ?? '',
|
||||
}));
|
||||
const created = await createResult({
|
||||
// The outcome is not sent: the API derives PASSED/FAILED from the
|
||||
// session's evaluation method and cutting point, and stamps the
|
||||
// examiner on the row (US-EXAM-011). The preview below shows what that
|
||||
// computation will produce.
|
||||
await createResult({
|
||||
seafarerId: selectedSeafarerId,
|
||||
examId: exam.id,
|
||||
resultBreakdowns: breakdowns,
|
||||
totalScore,
|
||||
remark: remark ? { en: remark, am: '' } : undefined,
|
||||
}).unwrap();
|
||||
if (passed && created.id) {
|
||||
await updateResult({ id: created.id, status: 'PASSED' }).unwrap();
|
||||
}
|
||||
notify.success(t('result.recordModal.saveSuccess'));
|
||||
setSelectedSeafarerId(null);
|
||||
setScores({});
|
||||
@@ -107,8 +131,17 @@ export function RecordResultModal({
|
||||
setRemark('');
|
||||
setSeafarerSearch('');
|
||||
onClose();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('result.recordModal.saveError'));
|
||||
notify.error(
|
||||
key === 'candidate_not_registered'
|
||||
? 'This candidate is not registered for the session.'
|
||||
: key.startsWith('candidate_not_present')
|
||||
? `No paper to mark — the register says ${key.split(':')[1] ?? ''}.`
|
||||
: key === 'result_already_recorded'
|
||||
? 'A result has already been recorded for this candidate.'
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconGavel, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetPendingAppealsQuery,
|
||||
useDecideAppealMutation,
|
||||
} from '../api/result-api';
|
||||
import type { ExamAppeal } from '../types/result';
|
||||
|
||||
/**
|
||||
* The appeals desk (US-EXAM-016).
|
||||
*
|
||||
* Upholding an appeal deliberately does not edit the mark here: it returns
|
||||
* the result to the examiner, so the correction travels the same moderation
|
||||
* and approval chain as the original.
|
||||
*/
|
||||
export function ExamAppealsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery();
|
||||
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
|
||||
|
||||
const [target, setTarget] = useState<ExamAppeal | null>(null);
|
||||
const [outcome, setOutcome] = useState<'UPHELD' | 'REJECTED'>('UPHELD');
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
const decide = async () => {
|
||||
if (!target) return;
|
||||
if (!remark.trim()) {
|
||||
notify.error(t('result.review.remarkRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await decideAppeal({ id: target.id, outcome, remark: remark.trim() }).unwrap();
|
||||
notify.success(t('result.appeals.decided'));
|
||||
setTarget(null);
|
||||
setRemark('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.appeals.error')));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="red">
|
||||
{t('result.appeals.error')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>{t('result.appeals.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('result.appeals.subtitle')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
{(appeals ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('result.appeals.none')}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('result.appeals.number')}</Table.Th>
|
||||
<Table.Th>{t('result.appeals.candidate')}</Table.Th>
|
||||
<Table.Th>{t('result.appeals.exam')}</Table.Th>
|
||||
<Table.Th>{t('result.appeals.reason')}</Table.Th>
|
||||
<Table.Th>{t('result.appeals.lodged')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(appeals ?? []).map((appeal) => (
|
||||
<Table.Tr key={appeal.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{appeal.appealNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">
|
||||
{appeal.profile
|
||||
? `${appeal.profile.firstName} ${appeal.profile.lastName}`
|
||||
: appeal.profileId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">
|
||||
{appeal.result?.exam?.title?.[locale] ?? '—'}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{appeal.result?.status} · {appeal.result?.totalScore}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" maw={300} lineClamp={3}>
|
||||
{appeal.reason}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => {
|
||||
setTarget(appeal);
|
||||
setOutcome('UPHELD');
|
||||
setRemark('');
|
||||
}}
|
||||
>
|
||||
{t('result.appeals.decide')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(target)}
|
||||
onClose={() => setTarget(null)}
|
||||
title={`${t('result.appeals.decide')} — ${target?.appealNumber ?? ''}`}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm">{target?.reason}</Text>
|
||||
<Select
|
||||
label={t('result.appeals.decide')}
|
||||
data={[
|
||||
{ value: 'UPHELD', label: t('result.appeals.uphold') },
|
||||
{ value: 'REJECTED', label: t('result.appeals.reject') },
|
||||
]}
|
||||
value={outcome}
|
||||
onChange={(value) =>
|
||||
setOutcome((value as 'UPHELD' | 'REJECTED') ?? 'UPHELD')
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
{outcome === 'UPHELD' && (
|
||||
<Alert color="yellow" icon={<IconInfoCircle size={15} />}>
|
||||
{t('result.appeals.upheldHint')}
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea
|
||||
label={t('result.appeals.remark')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setTarget(null)}>
|
||||
{t('result.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isDeciding} onClick={decide}>
|
||||
{t('result.appeals.decide')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExamAppealsPage;
|
||||
@@ -35,13 +35,24 @@ import {
|
||||
IconCircleX,
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetResultsQuery,
|
||||
useLazyGetResultQuery,
|
||||
useDeleteResultMutation,
|
||||
useUpdateResultMutation,
|
||||
useModerateResultMutation,
|
||||
useApproveResultMutation,
|
||||
useReturnResultMutation,
|
||||
usePublishExamResultsMutation,
|
||||
} from '../api/result-api';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
import { RecordResultModal } from '../components/RecordResultModal';
|
||||
import type { Result, ResultBreakdown } from '../types/result';
|
||||
import type { Result, ResultBreakdown, ResultReviewStatus } from '../types/result';
|
||||
import type { Exam } from '../../exam/types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -49,6 +60,16 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
FAILED: 'red',
|
||||
};
|
||||
|
||||
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
|
||||
const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
|
||||
MARKED: 'gray',
|
||||
MODERATED: 'yellow',
|
||||
APPROVED: 'blue',
|
||||
PUBLISHED: 'teal',
|
||||
};
|
||||
|
||||
type QcAction = 'moderate' | 'approve' | 'return';
|
||||
|
||||
function ResultStat({
|
||||
label,
|
||||
value,
|
||||
@@ -107,13 +128,16 @@ export function ResultPage() {
|
||||
|
||||
const [deleteResult] = useDeleteResultMutation();
|
||||
const [updateResult] = useUpdateResultMutation();
|
||||
const [moderateResult, { isLoading: isModerating }] = useModerateResultMutation();
|
||||
const [approveResult, { isLoading: isApproving }] = useApproveResultMutation();
|
||||
const [returnResult, { isLoading: isReturning }] = useReturnResultMutation();
|
||||
const [publishResults, { isLoading: isPublishing }] = usePublishExamResultsMutation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [examFilter, setExamFilter] = useState<string | null>(null);
|
||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [detailStatus, setDetailStatus] = useState<string>('PASSED');
|
||||
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
|
||||
const [detailSaving, setDetailSaving] = useState(false);
|
||||
@@ -121,6 +145,10 @@ export function ResultPage() {
|
||||
const [pickerOpened, { open: openPicker, close: closePicker }] = useDisclosure(false);
|
||||
const [recordExam, setRecordExam] = useState<Exam | null>(null);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
|
||||
const [qcTarget, setQcTarget] = useState<Result | null>(null);
|
||||
const [qcAction, setQcAction] = useState<QcAction>('approve');
|
||||
const [qcRemark, setQcRemark] = useState('');
|
||||
const [qcAdjustment, setQcAdjustment] = useState(0);
|
||||
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
|
||||
|
||||
@@ -165,7 +193,6 @@ export function ResultPage() {
|
||||
|
||||
const viewDetail = useCallback((result: Result) => {
|
||||
fetchDetail(result.id);
|
||||
setDetailStatus(result.status);
|
||||
setDetailRemark({ en: result.remark?.en ?? '', am: result.remark?.am ?? '' });
|
||||
setDetailBreakdowns(result.resultBreakdowns.map((b) => ({ ...b })));
|
||||
openDetail();
|
||||
@@ -175,26 +202,82 @@ export function ResultPage() {
|
||||
if (!detailResult) return;
|
||||
setDetailSaving(true);
|
||||
try {
|
||||
// No `status`: re-marking recomputes the outcome on the server, and an
|
||||
// approved result is refused until it is returned to the examiner.
|
||||
await updateResult({
|
||||
id: detailResult.id,
|
||||
status: detailStatus as 'PASSED' | 'FAILED',
|
||||
remark: detailRemark.en || detailRemark.am ? detailRemark : undefined,
|
||||
resultBreakdowns: detailBreakdowns,
|
||||
}).unwrap();
|
||||
notify.success(t('result.updated'));
|
||||
closeDetail();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('result.error'));
|
||||
notify.error(
|
||||
key === 'result_locked_after_approval'
|
||||
? t('result.review.lockedAfterApproval')
|
||||
: key,
|
||||
);
|
||||
} finally {
|
||||
setDetailSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openQc = (result: Result, action: QcAction) => {
|
||||
setQcTarget(result);
|
||||
setQcAction(action);
|
||||
setQcRemark('');
|
||||
setQcAdjustment(0);
|
||||
};
|
||||
|
||||
const runQc = async () => {
|
||||
if (!qcTarget) return;
|
||||
if (!qcRemark.trim()) {
|
||||
notify.error(t('result.review.remarkRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (qcAction === 'moderate') {
|
||||
await moderateResult({
|
||||
id: qcTarget.id,
|
||||
adjustment: qcAdjustment,
|
||||
remark: qcRemark.trim(),
|
||||
}).unwrap();
|
||||
notify.success(t('result.review.moderated'));
|
||||
} else if (qcAction === 'approve') {
|
||||
await approveResult({ id: qcTarget.id, remark: qcRemark.trim() }).unwrap();
|
||||
notify.success(t('result.review.approved'));
|
||||
} else {
|
||||
await returnResult({ id: qcTarget.id, remark: qcRemark.trim() }).unwrap();
|
||||
notify.success(t('result.review.returned'));
|
||||
}
|
||||
setQcTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Publication is per session (US-EXAM-014) — a cohort sees its results
|
||||
* together — so it needs an exam in the filter to know which one.
|
||||
*/
|
||||
const handlePublish = async () => {
|
||||
if (!examFilter) {
|
||||
notify.error(t('result.review.publishNeedsExam'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const outcome = await publishResults(examFilter).unwrap();
|
||||
notify.success(t('result.review.publishedCount', outcome));
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailClose = () => {
|
||||
closeDetail();
|
||||
setDetailBreakdowns([]);
|
||||
setDetailRemark({ en: '', am: '' });
|
||||
setDetailStatus('PASSED');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -286,10 +369,23 @@ export function ResultPage() {
|
||||
<Title order={2}>{t('result.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
loading={isPublishing}
|
||||
disabled={!examFilter}
|
||||
leftSection={<IconSend size={15} />}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('result.review.publish')}
|
||||
</Button>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
||||
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
|
||||
@@ -322,19 +418,113 @@ export function ResultPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('result.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('result.noItems')}
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.exam')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.status')}</Table.Th>
|
||||
<Table.Th>{t('result.review.column')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.date')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${r.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${r.reviewStatus}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => openQc(r, 'moderate')}
|
||||
>
|
||||
{t('result.review.moderate')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => openQc(r, 'approve')}
|
||||
>
|
||||
{t('result.review.approve')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => openQc(r, 'return')}
|
||||
>
|
||||
{t('result.review.return')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconEye size={13} />}
|
||||
onClick={() => viewDetail(r)}
|
||||
>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => { setDeleteTarget(r); openDelete(); }}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={detailOpened}
|
||||
@@ -385,16 +575,26 @@ export function ResultPage() {
|
||||
<Divider />
|
||||
|
||||
{/* Editable fields */}
|
||||
<Select
|
||||
label={t('result.detail.status')}
|
||||
data={[
|
||||
{ value: 'PASSED', label: t('result.status.PASSED') },
|
||||
{ value: 'FAILED', label: t('result.status.FAILED') },
|
||||
]}
|
||||
value={detailStatus}
|
||||
onChange={(v) => setDetailStatus(v ?? 'PASSED')}
|
||||
size="sm"
|
||||
/>
|
||||
{/* Read-only: the outcome follows the pass mark, not the officer. */}
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t('result.review.derivedStatus')}
|
||||
</Text>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}>
|
||||
{t(`result.status.${detailResult.status}`)}
|
||||
</Badge>
|
||||
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${detailResult.reviewStatus}`)}
|
||||
</Badge>
|
||||
{detailResult.preModerationScore != null && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('result.review.originalScore')}: {detailResult.preModerationScore}
|
||||
{detailResult.moderationRemark ? ` — ${detailResult.moderationRemark}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<BilingualInput
|
||||
label={t('result.detail.remark')}
|
||||
@@ -479,6 +679,53 @@ export function ResultPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Moderation, approval and return — the quality-control chain */}
|
||||
<Modal
|
||||
opened={Boolean(qcTarget)}
|
||||
onClose={() => setQcTarget(null)}
|
||||
title={t(`result.review.${qcAction}`)}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm">
|
||||
{qcTarget?.seafarer
|
||||
? `${qcTarget.seafarer.firstName} ${qcTarget.seafarer.lastName}`
|
||||
: qcTarget?.seafarerId.slice(0, 8)}{' '}
|
||||
· {qcTarget ? getExamTitle(qcTarget.examId) : ''} · {qcTarget?.totalScore}
|
||||
</Text>
|
||||
{qcAction === 'moderate' && (
|
||||
<TextInput
|
||||
label={t('result.review.adjustment')}
|
||||
description={t('result.review.adjustmentHint')}
|
||||
type="number"
|
||||
value={qcAdjustment}
|
||||
onChange={(e) => setQcAdjustment(Number(e.currentTarget.value))}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label={t('result.review.remark')}
|
||||
value={qcRemark}
|
||||
onChange={(e) => setQcRemark(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setQcTarget(null)}>
|
||||
{t('result.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={isModerating || isApproving || isReturning}
|
||||
onClick={runQc}
|
||||
>
|
||||
{t(`result.review.${qcAction}`)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('result.deleteConfirmText')}</Text>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -5,6 +5,15 @@ export type { QuestionForm };
|
||||
|
||||
export type ExamResultStatus = 'PASSED' | 'FAILED';
|
||||
|
||||
/** How far a mark has travelled through quality control (US-EXAM-011 → 014). */
|
||||
export type ResultReviewStatus = 'MARKED' | 'MODERATED' | 'APPROVED' | 'PUBLISHED';
|
||||
|
||||
export type ExamAppealStatus =
|
||||
| 'SUBMITTED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'UPHELD'
|
||||
| 'REJECTED';
|
||||
|
||||
export interface ResultBreakdown {
|
||||
questionId: string;
|
||||
score: number;
|
||||
@@ -52,27 +61,70 @@ export interface Result {
|
||||
totalScore: number;
|
||||
remark: { en: string; am: string } | null;
|
||||
status: ExamResultStatus;
|
||||
reviewStatus: ResultReviewStatus;
|
||||
markedById: string | null;
|
||||
markedAt: string | null;
|
||||
preModerationScore: number | null;
|
||||
moderationAdjustment: number | null;
|
||||
moderationRemark: string | null;
|
||||
approvalRemark: string | null;
|
||||
approvedAt: string | null;
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** A candidate's challenge to a published result (US-EXAM-016). */
|
||||
export interface ExamAppeal {
|
||||
id: string;
|
||||
appealNumber: string;
|
||||
resultId: string;
|
||||
profileId: string;
|
||||
reason: string;
|
||||
status: ExamAppealStatus;
|
||||
decisionRemark: string | null;
|
||||
decidedAt: string | null;
|
||||
createdAt: string;
|
||||
result?: Result;
|
||||
profile?: Profile;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
total: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/** The total and the outcome are both computed server-side (US-EXAM-011). */
|
||||
export interface CreateResultPayload {
|
||||
seafarerId: string;
|
||||
examId: string;
|
||||
resultBreakdowns: ResultBreakdown[];
|
||||
totalScore?: number;
|
||||
remark?: { en: string; am: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* `status` is absent on purpose: PASSED/FAILED is derived on the server from
|
||||
* the session's cutting point and evaluation method (US-EXAM-011).
|
||||
*/
|
||||
export interface UpdateResultPayload {
|
||||
id: string;
|
||||
resultBreakdowns?: ResultBreakdown[];
|
||||
totalScore?: number;
|
||||
remark?: { en: string; am: string };
|
||||
status?: ExamResultStatus;
|
||||
}
|
||||
|
||||
export interface ModerateResultPayload {
|
||||
id: string;
|
||||
adjustment: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface ResultDecisionPayload {
|
||||
id: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface DecideAppealPayload {
|
||||
id: string;
|
||||
outcome: 'UPHELD' | 'REJECTED';
|
||||
remark: string;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import { AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconSearch,
|
||||
IconShieldCog,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useApiQuery,
|
||||
useGetMedicalForProfileQuery,
|
||||
useGetSeaServiceForProfileQuery,
|
||||
useUpdateSeafarerStatusMutation,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
@@ -20,19 +41,295 @@ interface ProfileRow {
|
||||
gender?: string;
|
||||
type?: string;
|
||||
isComplete?: boolean;
|
||||
seafarerNumber?: string | null;
|
||||
seafarerStatus?: string | null;
|
||||
seafarerDepartment?: string | null;
|
||||
seafarerStatusReason?: string | null;
|
||||
profession?: { name?: { en?: string } };
|
||||
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
|
||||
}
|
||||
|
||||
const SEAFARER_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
PENDING: 'yellow',
|
||||
SUSPENDED: 'orange',
|
||||
INACTIVE: 'gray',
|
||||
};
|
||||
|
||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
const DEPARTMENT_LABELS: Record<string, string> = {
|
||||
DECK: 'Deck',
|
||||
ENGINE: 'Engine',
|
||||
CATERING: 'Catering',
|
||||
};
|
||||
|
||||
/** The registered seafarer's records, read-only (verification is module 06). */
|
||||
function SeafarerDetailDrawer({
|
||||
profile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const profileId = profile?.id ?? '';
|
||||
const { data: seaService, isLoading: loadingSea } =
|
||||
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
||||
const { data: medical, isLoading: loadingMedical } =
|
||||
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size="lg"
|
||||
title={
|
||||
profile
|
||||
? [profile.firstName, profile.middleName, profile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{profile && (
|
||||
<Stack>
|
||||
<Group gap="xl">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Seafarer number
|
||||
</Text>
|
||||
<Text fw={700} ff="monospace">
|
||||
{profile.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Department
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{profile.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment
|
||||
: '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Status
|
||||
</Text>
|
||||
<Badge
|
||||
color={
|
||||
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
|
||||
}
|
||||
>
|
||||
{profile.seafarerStatus ?? 'NOT REGISTERED'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
{profile.seafarerStatusReason && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Status reason: {profile.seafarerStatusReason}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
||||
Sea Service
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
||||
Medical
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="sm">
|
||||
{loadingSea ? (
|
||||
<Loader size="sm" />
|
||||
) : (seaService ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No sea-service records.
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>Period</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(seaService ?? []).map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>
|
||||
{record.engagementDate} → {record.dischargeDate}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="medical" pt="sm">
|
||||
{loadingMedical ? (
|
||||
<Loader size="sm" />
|
||||
) : (medical ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No medical certificates.
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Validity</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(medical ?? []).map((certificate) => (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>{certificate.issuerName}</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issueDate} → {certificate.expiryDate}
|
||||
</Table.Td>
|
||||
<Table.Td>{certificate.fitnessStatus}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={RECORD_STATUS_COLORS[certificate.status]}
|
||||
>
|
||||
{certificate.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** US-SEA-013: suspend / reinstate / close, always with a reason. */
|
||||
function StatusModal({
|
||||
profile,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
||||
|
||||
const submit = async () => {
|
||||
if (!profile || !status) return;
|
||||
try {
|
||||
await updateStatus({
|
||||
profileId: profile.id,
|
||||
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
||||
reason,
|
||||
}).unwrap();
|
||||
notify.success('Seafarer status updated');
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not update the status'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
title="Change seafarer status"
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{profile?.seafarerNumber} — currently {profile?.seafarerStatus}. The
|
||||
reason is recorded and visible to the seafarer.
|
||||
</Text>
|
||||
<Select
|
||||
label="New status"
|
||||
required
|
||||
data={[
|
||||
{ value: 'SUSPENDED', label: 'Suspend' },
|
||||
{ value: 'INACTIVE', label: 'Close' },
|
||||
{ value: 'ACTIVE', label: 'Reinstate' },
|
||||
].filter((o) => o.value !== profile?.seafarerStatus)}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
required
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
||||
disabled={!status || reason.trim().length < 3}
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered seafarer profiles, read from the real profiles endpoint.
|
||||
*
|
||||
* This previously listed a hardcoded roster, so the registry showed seafarers
|
||||
* who had never registered.
|
||||
* Registration review itself happens in the licence queue (the
|
||||
* SEAFARER_REGISTRATION application type); this page is the resulting
|
||||
* register — numbers, departments, statuses, and each seafarer's records.
|
||||
*/
|
||||
export function SeafarerRegistryPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const { data, isFetching, refetch } = useApiQuery<{ total: number; items: ProfileRow[] }>({
|
||||
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
||||
const { data, isLoading, refetch } = useApiQuery<{
|
||||
total: number;
|
||||
items: ProfileRow[];
|
||||
}>({
|
||||
url: '/profiles',
|
||||
method: 'GET',
|
||||
params: { q: 'i=profession,address&t=200' },
|
||||
@@ -42,7 +339,13 @@ export function SeafarerRegistryPage() {
|
||||
const items = (data?.items ?? []).filter((p) => {
|
||||
if (!search.trim()) return true;
|
||||
const term = search.toLowerCase();
|
||||
return [p.firstName, p.middleName, p.lastName, p.address?.idNumber]
|
||||
return [
|
||||
p.firstName,
|
||||
p.middleName,
|
||||
p.lastName,
|
||||
p.address?.idNumber,
|
||||
p.seafarerNumber,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term));
|
||||
});
|
||||
@@ -85,32 +388,118 @@ export function SeafarerRegistryPage() {
|
||||
<div>
|
||||
<Title order={3}>Seafarer registry</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} registered profile{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
{data?.total ?? 0} profile{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name or ID number"
|
||||
placeholder="Name, ID or seafarer number"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.currentTarget.value); setPageIndex(0); }}
|
||||
w={260}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName="Seafarer registry"
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : items.length === 0 ? (
|
||||
<Center h={160}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Seafarer №</Table.Th>
|
||||
<Table.Th>Department</Table.Th>
|
||||
<Table.Th>ID number</Table.Th>
|
||||
<Table.Th>Phone</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((p) => (
|
||||
<Table.Tr
|
||||
key={p.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setDetail(p)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" ff="monospace">
|
||||
{p.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{p.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[p.seafarerDepartment] ?? p.seafarerDepartment
|
||||
: '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.idNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.primaryPhoneNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{p.seafarerNumber ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={SEAFARER_STATUS_COLORS[p.seafarerStatus ?? ''] ?? 'gray'}
|
||||
>
|
||||
{p.seafarerStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
|
||||
{p.isComplete ? 'Not registered' : 'Incomplete'}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
{p.seafarerNumber && (
|
||||
<Tooltip label="Suspend / reinstate / close">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => setStatusTarget(p)}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
|
||||
<StatusModal
|
||||
profile={statusTarget}
|
||||
onClose={() => setStatusTarget(null)}
|
||||
onDone={refetch}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,359 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
IconShieldCog,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetVesselIncidentsQuery,
|
||||
useGetVesselsQuery,
|
||||
useUpdateVesselStatusMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { Vessel } from '@ema-platform/api';
|
||||
|
||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||
REGISTERED: 'green',
|
||||
SUSPENDED: 'orange',
|
||||
DEREGISTERED: 'gray',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
INLAND_WATERWAY: 'Inland Waterway',
|
||||
SEA_GOING: 'Sea-going',
|
||||
};
|
||||
|
||||
/** Full particulars + incident log, read-only. */
|
||||
function VesselDetailDrawer({
|
||||
vessel,
|
||||
onClose,
|
||||
}: {
|
||||
vessel: Vessel | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data: incidents, isLoading: loadingIncidents } =
|
||||
useGetVesselIncidentsQuery(vessel?.id ?? '', { skip: !vessel });
|
||||
|
||||
const particulars: [string, string | number | null][] = vessel
|
||||
? [
|
||||
['Registration №', vessel.registrationNumber],
|
||||
['Category', CATEGORY_LABELS[vessel.category] ?? vessel.category],
|
||||
['Type', vessel.vesselType],
|
||||
['IMO number', vessel.imoNumber],
|
||||
['Hull number', vessel.hullNumber],
|
||||
['Flag state', vessel.flagState],
|
||||
['Port of registry', vessel.portOfRegistry],
|
||||
['Gross tonnage', vessel.grossTonnage],
|
||||
['Passenger capacity', vessel.passengerCapacity],
|
||||
['Length (m)', vessel.lengthMeters],
|
||||
['Year built', vessel.yearBuilt],
|
||||
['Engine', vessel.engineType],
|
||||
['Engine power (kW)', vessel.enginePowerKw],
|
||||
['Engines', vessel.numberOfEngines],
|
||||
['Hull material', vessel.hullMaterial],
|
||||
['Owner', vessel.ownerName],
|
||||
['Registered', vessel.registeredAt?.slice(0, 10) ?? null],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={Boolean(vessel)}
|
||||
onClose={onClose}
|
||||
position="right"
|
||||
size="lg"
|
||||
title={vessel?.name ?? ''}
|
||||
>
|
||||
{vessel && (
|
||||
<Stack>
|
||||
{vessel.statusReason && (
|
||||
<Alert
|
||||
color={vessel.status === 'SUSPENDED' ? 'orange' : 'gray'}
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
{vessel.status}: {vessel.statusReason}
|
||||
</Alert>
|
||||
)}
|
||||
<Table variant="vertical" layout="fixed">
|
||||
<Table.Tbody>
|
||||
{particulars
|
||||
.filter(([, value]) => value !== null && value !== undefined)
|
||||
.map(([label, value]) => (
|
||||
<Table.Tr key={label}>
|
||||
<Table.Th w={180}>{label}</Table.Th>
|
||||
<Table.Td>{String(value)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Group gap="xs">
|
||||
<IconAlertTriangle size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
Incidents
|
||||
</Text>
|
||||
</Group>
|
||||
{loadingIncidents ? (
|
||||
<Loader size="sm" />
|
||||
) : (incidents ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No incidents recorded.
|
||||
</Text>
|
||||
) : (
|
||||
(incidents ?? []).map((incident) => (
|
||||
<Card key={incident.id} withBorder radius="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{incident.occurredAt}
|
||||
{incident.location ? ` — ${incident.location}` : ''}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light">
|
||||
{incident.reportedByOfficer ? 'Officer' : 'Owner'}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" mt={4}>
|
||||
{incident.description}
|
||||
</Text>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Suspend / deregister / reinstate with a mandatory reason (US-VES-015). */
|
||||
function StatusModal({
|
||||
vessel,
|
||||
onClose,
|
||||
}: {
|
||||
vessel: Vessel | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [updateStatus, { isLoading }] = useUpdateVesselStatusMutation();
|
||||
|
||||
const submit = async () => {
|
||||
if (!vessel || !status) return;
|
||||
try {
|
||||
await updateStatus({
|
||||
vesselId: vessel.id,
|
||||
status: status as Vessel['status'],
|
||||
reason,
|
||||
}).unwrap();
|
||||
notify.success('Vessel status updated');
|
||||
onClose();
|
||||
setStatus(null);
|
||||
setReason('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not update the vessel'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(vessel)}
|
||||
onClose={onClose}
|
||||
title="Change vessel status"
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{vessel?.registrationNumber} — currently {vessel?.status}.
|
||||
Deregistration is terminal. The reason is recorded and visible to the
|
||||
owner.
|
||||
</Text>
|
||||
<Select
|
||||
label="New status"
|
||||
required
|
||||
data={[
|
||||
{ value: 'SUSPENDED', label: 'Suspend' },
|
||||
{ value: 'DEREGISTERED', label: 'Deregister' },
|
||||
{ value: 'REGISTERED', label: 'Reinstate' },
|
||||
].filter((option) => option.value !== vessel?.status)}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
required
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={status === 'REGISTERED' ? 'green' : 'orange'}
|
||||
disabled={!status || reason.trim().length < 3}
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
* The national vessel register (module 11).
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
* Entries appear here automatically when a VESSEL_REGISTRATION application's
|
||||
* certificate is issued; the applications themselves are reviewed in the
|
||||
* ordinary licence queue.
|
||||
*/
|
||||
export function VesselRegistrationQueuePage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const { data, isLoading } = useGetVesselsQuery(
|
||||
search.trim() ? { search: search.trim() } : undefined,
|
||||
);
|
||||
const [detail, setDetail] = useState<Vessel | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<Vessel | null>(null);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel registration queue"
|
||||
description="Vessel registration is not connected to the backend yet."
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Vessel register</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'} —
|
||||
pending registrations are reviewed in the{' '}
|
||||
<Text component={Link} to="/licence-review" inherit c="blue">
|
||||
licence queue
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name, registration № or IMO"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
{isLoading ? (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : items.length === 0 ? (
|
||||
<Center h={160}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{search
|
||||
? 'No vessels match that search.'
|
||||
: 'No vessels registered yet.'}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Registration №</Table.Th>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Registered</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((vessel) => (
|
||||
<Table.Tr
|
||||
key={vessel.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setDetail(vessel)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text size="sm" ff="monospace" fw={600}>
|
||||
{vessel.registrationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{vessel.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{vessel.vesselType ?? '—'}
|
||||
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{vessel.ownerName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{vessel.registeredAt?.slice(0, 10)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={VESSEL_STATUS_COLORS[vessel.status]}
|
||||
>
|
||||
{vessel.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip label="Suspend / deregister / reinstate">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => setStatusTarget(vessel)}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<VesselDetailDrawer vessel={detail} onClose={() => setDetail(null)} />
|
||||
<StatusModal
|
||||
vessel={statusTarget}
|
||||
onClose={() => setStatusTarget(null)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function WaiverQueuePage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Waiver queue"
|
||||
description="Waiver applications are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default WaiverQueuePage;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function WaiverReviewPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Waiver review"
|
||||
description="Waiver applications are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default WaiverReviewPage;
|
||||
@@ -28,6 +28,7 @@ export const am: Translations = {
|
||||
nav: {
|
||||
groupLicensing: 'ፈቃድ አሰጣጥ',
|
||||
allApplications: 'ሁሉም ማመልከቻዎች',
|
||||
licenceRegister: 'የፈቃድ መዝገብ',
|
||||
certificateDesigner: 'የምስክር ወረቀት ንድፍ',
|
||||
byType: 'በዓይነት',
|
||||
typeFreightForwarder: 'የጭነት አስተላላፊ',
|
||||
@@ -51,7 +52,7 @@ export const am: Translations = {
|
||||
details: 'ዝርዝር',
|
||||
licenceReview: 'የፈቃድ ማመልከቻዎች',
|
||||
vesselRegistrationHeadDashboard: 'የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ',
|
||||
vesselRegistrationQueue: 'የመርከብ ምዝገባ ወረፋ',
|
||||
vesselRegistrationQueue: 'የመርከብ መዝገብ',
|
||||
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
|
||||
vesselRegistrationReport: 'የመርከብ ምዝገባ ሪፖርት',
|
||||
ownershipTransferQueue: 'የባለቤትነት ዝውውር ወረፋ',
|
||||
@@ -62,6 +63,8 @@ export const am: Translations = {
|
||||
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
|
||||
mtoLicense: 'የMTO ፈቃድ',
|
||||
waiver: 'ነፃ ፈቃድ',
|
||||
preWaiverQueue: 'የቅድመ ነፃ ፈቃድ ወረፋ',
|
||||
postWaiverQueue: 'የድህረ ነፃ ፈቃድ ወረፋ',
|
||||
menu: 'ምናሌ',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
userManagement: 'የተጠቃሚ አስተዳደር',
|
||||
@@ -82,6 +85,7 @@ export const am: Translations = {
|
||||
questions: 'ጥያቄዎች',
|
||||
exams: 'ፈተናዎች',
|
||||
examResults: 'የፈተና ውጤቶች',
|
||||
examAppeals: 'የፈተና ይግባኞች',
|
||||
collapseSidebar: 'ሰብስብ',
|
||||
expandSidebar: 'ዘርጋ',
|
||||
},
|
||||
@@ -290,6 +294,63 @@ export const am: Translations = {
|
||||
MANUAL: 'በእጅ',
|
||||
RANDOM: 'በዘፈቀደ',
|
||||
},
|
||||
candidates: {
|
||||
section: 'ተፈታኞች',
|
||||
admission: 'የመግቢያ ቁጥር',
|
||||
name: 'ተፈታኝ',
|
||||
attempt: 'ሙከራ',
|
||||
attendance: 'ተገኝነት',
|
||||
remark: 'አስተያየት',
|
||||
record: 'መዝግብ',
|
||||
recorded: 'ተገኝነት ተመዝግቧል',
|
||||
none: 'ለዚህ ፈተና እስካሁን የተመዘገበ ተፈታኝ የለም።',
|
||||
retake: 'ድጋሚ {{n}}',
|
||||
firstSitting: 'የመጀመሪያ ሙከራ',
|
||||
remarkRequired: 'ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።',
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
ABSENT: 'አልተገኘም',
|
||||
LATE: 'ዘግይቷል',
|
||||
WITHDRAWN: 'ወጥቷል',
|
||||
DISQUALIFIED: 'ታግዷል',
|
||||
},
|
||||
incidents: {
|
||||
section: 'ክስተቶች',
|
||||
add: 'ክስተት መዝግብ',
|
||||
type: 'ዓይነት',
|
||||
candidate: 'ተፈታኝ',
|
||||
wholeRoom: 'ሙሉ ፈተና',
|
||||
description: 'የተከሰተው ነገር',
|
||||
occurred: 'የተከሰተበት',
|
||||
status: 'ሁኔታ',
|
||||
resolve: 'ፍታ',
|
||||
resolution: 'ውሳኔ',
|
||||
outcome: 'ውጤት',
|
||||
none: 'ለዚህ ፈተና የተመዘገበ ክስተት የለም።',
|
||||
filed: 'ክስተት ተመዝግቧል',
|
||||
resolved: 'ክስተት ተዘግቷል',
|
||||
error: 'ተግባሩ አልተሳካም',
|
||||
},
|
||||
incidentType: {
|
||||
MISCONDUCT: 'ሥነ ምግባር ጥሰት',
|
||||
TECHNICAL_FAILURE: 'ቴክኒካዊ ብልሽት',
|
||||
MEDICAL: 'የጤና',
|
||||
ADMINISTRATIVE: 'አስተዳደራዊ',
|
||||
OTHER: 'ሌላ',
|
||||
},
|
||||
incidentStatus: {
|
||||
OPEN: 'ክፍት',
|
||||
UNDER_REVIEW: 'በግምገማ ላይ',
|
||||
RESOLVED: 'ተፈትቷል',
|
||||
DISMISSED: 'ውድቅ ተደርጓል',
|
||||
},
|
||||
randomHintServer:
|
||||
'ጥያቄዎች ለዚህ ትምህርት ከጸደቁ ንጥሎች በሰርቨሩ ላይ ይመረጣሉ፤ ስለዚህ ማከማቻው ለአሳሹ አይጋለጥም።',
|
||||
randomSelected: '{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል',
|
||||
randomError: 'ጥያቄዎችን መምረጥ አልተቻለም',
|
||||
notEnoughApproved: 'ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።',
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -556,6 +617,49 @@ export const am: Translations = {
|
||||
filterByExam: 'በፈተና አጣራ',
|
||||
allExams: 'ሁሉም ፈተናዎች',
|
||||
},
|
||||
review: {
|
||||
column: 'ግምገማ',
|
||||
MARKED: 'ተስተካክሏል',
|
||||
MODERATED: 'ተመርምሯል',
|
||||
APPROVED: 'ጸድቋል',
|
||||
PUBLISHED: 'ወጥቷል',
|
||||
moderate: 'አስተካክል',
|
||||
approve: 'አጽድቅ',
|
||||
return: 'ወደ ፈታኙ መልስ',
|
||||
publish: 'የፈተናውን ውጤቶች አውጣ',
|
||||
adjustment: 'በጠቅላላ ውጤት ላይ የሚደረግ ማስተካከያ',
|
||||
adjustmentHint: 'እንደ 3 ወይም -2 ያለ ለውጥ። ዜሮ ውጤቱን እንዳለ ያጸድቃል።',
|
||||
remark: 'ምክንያት',
|
||||
remarkRequired: 'ምክንያት ያስፈልጋል።',
|
||||
moderated: 'ውጤቶች ተስተካክለዋል',
|
||||
approved: 'ውጤት ጸድቋል',
|
||||
returned: 'ውጤት ወደ ፈታኙ ተመልሷል',
|
||||
publishedCount: '{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።',
|
||||
publishNeedsExam: 'ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።',
|
||||
lockedAfterApproval:
|
||||
'ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።',
|
||||
originalScore: 'የፈታኙ ጠቅላላ',
|
||||
derivedStatus: 'ውጤት (ከማለፊያ ነጥብ የተገኘ)',
|
||||
error: 'ተግባሩ አልተሳካም',
|
||||
},
|
||||
appeals: {
|
||||
title: 'የፈተና ይግባኞች',
|
||||
subtitle: 'ተፈታኞች በወጡ ውጤቶች ላይ ያቀረቡት ቅሬታ',
|
||||
number: 'የይግባኝ ቁጥር',
|
||||
candidate: 'ተፈታኝ',
|
||||
exam: 'ፈተና',
|
||||
reason: 'ምክንያት',
|
||||
lodged: 'የቀረበበት',
|
||||
decide: 'ወስን',
|
||||
uphold: 'ተቀበል',
|
||||
reject: 'አትቀበል',
|
||||
remark: 'ውሳኔና ምክንያት',
|
||||
decided: 'ይግባኙ ተወስኗል',
|
||||
none: 'ውሳኔ የሚጠብቅ ይግባኝ የለም።',
|
||||
upheldHint:
|
||||
'ይግባኙን መቀበል ውጤቱን ለድጋሚ እርማት ወደ ፈታኙ ይመልሰዋል፤ እስኪጸድቅና እንደገና እስኪወጣ ተፈታኙ አያየውም።',
|
||||
error: 'ውሳኔውን መመዝገብ አልተቻለም',
|
||||
},
|
||||
},
|
||||
|
||||
question: {
|
||||
@@ -605,6 +709,25 @@ export const am: Translations = {
|
||||
active: 'ንቁ',
|
||||
inactive: 'እንቅስቃሴ የሌለ',
|
||||
},
|
||||
qc: {
|
||||
column: 'ማጽደቅ',
|
||||
DRAFT: 'ረቂቅ',
|
||||
PENDING_APPROVAL: 'ማጽደቅ በመጠባበቅ ላይ',
|
||||
APPROVED: 'ጸድቋል',
|
||||
REJECTED: 'ተቀባይነት አላገኘም',
|
||||
RETIRED: 'ከአገልግሎት ወጥቷል',
|
||||
submit: 'ለማጽደቅ ላክ',
|
||||
approve: 'አጽድቅ',
|
||||
reject: 'አትቀበል',
|
||||
retire: 'ከአገልግሎት አውጣ',
|
||||
remark: 'ምክንያት',
|
||||
remarkRequired: 'ላለመቀበል ወይም ከአገልግሎት ለማውጣት ምክንያት ያስፈልጋል።',
|
||||
submitted: 'ለማጽደቅ ተልኳል',
|
||||
reviewed: 'ውሳኔ ተመዝግቧል',
|
||||
reviewTitle: 'የጥያቄ ማከማቻ ንጥል ግምገማ',
|
||||
onlyApprovedUsable: 'የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።',
|
||||
error: 'ተግባሩ አልተሳካም',
|
||||
},
|
||||
},
|
||||
|
||||
configuration: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const en = {
|
||||
nav: {
|
||||
groupLicensing: 'Licensing',
|
||||
allApplications: 'All Applications',
|
||||
licenceRegister: 'Licence Register',
|
||||
certificateDesigner: 'Certificate Designer',
|
||||
byType: 'By Type',
|
||||
typeFreightForwarder: 'Freight Forwarder',
|
||||
@@ -53,7 +54,7 @@ export const en = {
|
||||
userManagement: 'User Management',
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
||||
vesselRegistrationQueue: 'Vessel Registration Queue',
|
||||
vesselRegistrationQueue: 'Vessel Register',
|
||||
vesselFormBuilder: 'Vessel Form Builder',
|
||||
vesselRegistrationReport: 'Vessel Registration Report',
|
||||
ownershipTransferQueue: 'Ownership Transfer Queue',
|
||||
@@ -64,6 +65,8 @@ export const en = {
|
||||
jointInvestmentLicense: 'Joint Investment License',
|
||||
mtoLicense: 'MTO License',
|
||||
waiver: 'Waiver',
|
||||
preWaiverQueue: 'Pre-Waiver Queue',
|
||||
postWaiverQueue: 'Post-Waiver Queue',
|
||||
cocQueue: 'CoC / CoP Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
@@ -80,6 +83,7 @@ export const en = {
|
||||
questions: 'Questions',
|
||||
exams: 'Examinations',
|
||||
examResults: 'Exam Results',
|
||||
examAppeals: 'Exam Appeals',
|
||||
collapseSidebar: 'Collapse',
|
||||
expandSidebar: 'Expand sidebar',
|
||||
},
|
||||
@@ -288,6 +292,64 @@ export const en = {
|
||||
MANUAL: 'Manual',
|
||||
RANDOM: 'Random',
|
||||
},
|
||||
candidates: {
|
||||
section: 'Candidates',
|
||||
admission: 'Admission №',
|
||||
name: 'Candidate',
|
||||
attempt: 'Attempt',
|
||||
attendance: 'Attendance',
|
||||
remark: 'Remark',
|
||||
record: 'Record',
|
||||
recorded: 'Attendance recorded',
|
||||
none: 'No candidates have registered for this session yet.',
|
||||
retake: 'Retake {{n}}',
|
||||
firstSitting: 'First sitting',
|
||||
remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
|
||||
},
|
||||
attendance: {
|
||||
REGISTERED: 'Not called',
|
||||
PRESENT: 'Present',
|
||||
ABSENT: 'Absent',
|
||||
LATE: 'Late',
|
||||
WITHDRAWN: 'Withdrawn',
|
||||
DISQUALIFIED: 'Disqualified',
|
||||
},
|
||||
incidents: {
|
||||
section: 'Incidents',
|
||||
add: 'Record incident',
|
||||
type: 'Type',
|
||||
candidate: 'Candidate',
|
||||
wholeRoom: 'Whole session',
|
||||
description: 'What happened',
|
||||
occurred: 'Occurred',
|
||||
status: 'Status',
|
||||
resolve: 'Resolve',
|
||||
resolution: 'Ruling',
|
||||
outcome: 'Outcome',
|
||||
none: 'No incidents recorded for this session.',
|
||||
filed: 'Incident recorded',
|
||||
resolved: 'Incident closed',
|
||||
error: 'Operation failed',
|
||||
},
|
||||
incidentType: {
|
||||
MISCONDUCT: 'Misconduct',
|
||||
TECHNICAL_FAILURE: 'Technical failure',
|
||||
MEDICAL: 'Medical',
|
||||
ADMINISTRATIVE: 'Administrative',
|
||||
OTHER: 'Other',
|
||||
},
|
||||
incidentStatus: {
|
||||
OPEN: 'Open',
|
||||
UNDER_REVIEW: 'Under review',
|
||||
RESOLVED: 'Resolved',
|
||||
DISMISSED: 'Dismissed',
|
||||
},
|
||||
randomHintServer:
|
||||
'Questions are drawn on the server from approved bank items for this subject, so the pool is never exposed to the browser.',
|
||||
randomSelected: 'Drew {{count}} approved questions',
|
||||
randomError: 'Could not draw questions',
|
||||
notEnoughApproved:
|
||||
'Not enough approved questions in the bank for this subject.',
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -555,6 +617,50 @@ export const en = {
|
||||
filterByExam: 'Filter by exam',
|
||||
allExams: 'All Exams',
|
||||
},
|
||||
review: {
|
||||
column: 'Review',
|
||||
MARKED: 'Marked',
|
||||
MODERATED: 'Moderated',
|
||||
APPROVED: 'Approved',
|
||||
PUBLISHED: 'Published',
|
||||
moderate: 'Moderate',
|
||||
approve: 'Approve',
|
||||
return: 'Return to examiner',
|
||||
publish: 'Publish session results',
|
||||
adjustment: 'Adjustment to the total',
|
||||
adjustmentHint:
|
||||
'A signed change, e.g. 3 or -2. Zero endorses the mark as it stands.',
|
||||
remark: 'Reason',
|
||||
remarkRequired: 'A reason is required.',
|
||||
moderated: 'Marks moderated',
|
||||
approved: 'Result approved',
|
||||
returned: 'Result returned to the examiner',
|
||||
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
|
||||
publishNeedsExam: 'Filter by an exam first to publish its results.',
|
||||
lockedAfterApproval:
|
||||
'This result is approved and can no longer be edited. Return it to the examiner first.',
|
||||
originalScore: 'Examiner total',
|
||||
derivedStatus: 'Outcome (derived from the pass mark)',
|
||||
error: 'Operation failed',
|
||||
},
|
||||
appeals: {
|
||||
title: 'Examination appeals',
|
||||
subtitle: 'Candidate challenges to published results',
|
||||
number: 'Appeal №',
|
||||
candidate: 'Candidate',
|
||||
exam: 'Examination',
|
||||
reason: 'Grounds',
|
||||
lodged: 'Lodged',
|
||||
decide: 'Decide',
|
||||
uphold: 'Uphold',
|
||||
reject: 'Reject',
|
||||
remark: 'Decision and reason',
|
||||
decided: 'Appeal decided',
|
||||
none: 'No appeals are awaiting a ruling.',
|
||||
upheldHint:
|
||||
'Upholding returns the result to the examiner for re-marking; the candidate stops seeing it until it is approved and published again.',
|
||||
error: 'Could not record the decision',
|
||||
},
|
||||
},
|
||||
|
||||
question: {
|
||||
@@ -604,6 +710,26 @@ export const en = {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
},
|
||||
qc: {
|
||||
column: 'Approval',
|
||||
DRAFT: 'Draft',
|
||||
PENDING_APPROVAL: 'Awaiting approval',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
RETIRED: 'Retired',
|
||||
submit: 'Submit for approval',
|
||||
approve: 'Approve',
|
||||
reject: 'Reject',
|
||||
retire: 'Retire',
|
||||
remark: 'Reason',
|
||||
remarkRequired: 'A reason is required to reject or retire an item.',
|
||||
submitted: 'Sent for approval',
|
||||
reviewed: 'Decision recorded',
|
||||
reviewTitle: 'Review question bank item',
|
||||
onlyApprovedUsable:
|
||||
'Only approved items can be placed on an examination paper.',
|
||||
error: 'Operation failed',
|
||||
},
|
||||
},
|
||||
|
||||
configuration: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconFileDescription,
|
||||
IconFilePlus,
|
||||
IconGauge,
|
||||
IconGavel,
|
||||
IconHeart,
|
||||
IconLayoutDashboard,
|
||||
IconListCheck,
|
||||
@@ -35,6 +36,7 @@ import type { NavSection } from '@ema-platform/ui';
|
||||
export const PERMISSIONS = {
|
||||
VIEW_APPLICATION_QUEUE: 'can:View:license-application-queue',
|
||||
VIEW_APPLICATIONS: 'can:View:license-applications',
|
||||
VIEW_LICENSES: 'can:View:licenses',
|
||||
VIEW_LICENSE_TYPES: 'can:View:license-types',
|
||||
VIEW_PAYMENTS: 'can:View:license-payments',
|
||||
VIEW_TEMPLATES: 'can:View:license-templates',
|
||||
@@ -76,13 +78,20 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor },
|
||||
],
|
||||
},
|
||||
{
|
||||
to: '/licence-register',
|
||||
label: 'nav.licenceRegister',
|
||||
icon: IconListCheck,
|
||||
permissions: [PERMISSIONS.VIEW_LICENSES],
|
||||
},
|
||||
{
|
||||
to: '/certificate-designer',
|
||||
label: 'nav.certificateDesigner',
|
||||
icon: IconRosetteDiscountCheck,
|
||||
permissions: [PERMISSIONS.VIEW_TEMPLATES],
|
||||
},
|
||||
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
|
||||
{ to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff },
|
||||
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff },
|
||||
{
|
||||
to: '/logistics-head-dashboard',
|
||||
label: 'nav.logisticsHeadDashboard',
|
||||
@@ -100,16 +109,16 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
label: 'nav.groupSeafarer',
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
items: [
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
||||
@@ -122,6 +131,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
||||
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
||||
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
||||
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
LoginPage,
|
||||
ForgotPasswordPage,
|
||||
SetPasswordPage,
|
||||
OTPVerificationPage,
|
||||
} from '@ema-platform/auth';
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
@@ -18,8 +19,6 @@ import { ConfigurationPage } from '../features/configuration/pages/Configuration
|
||||
import { LocationPage } from '../features/location/pages/LocationPage';
|
||||
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
||||
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
|
||||
import { CoCQueuePage } from '../features/coc-queue/pages/CoCQueuePage';
|
||||
import { CoCReviewPage } from '../features/coc-queue/pages/CoCReviewPage';
|
||||
import { EndorsementQueuePage } from '../features/endorsement/pages/EndorsementQueuePage';
|
||||
import { EndorsementReviewPage } from '../features/endorsement/pages/EndorsementReviewPage';
|
||||
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
@@ -30,14 +29,17 @@ import { QuestionPage } from '../features/question/pages/QuestionPage';
|
||||
import { ExamPage } from '../features/exam/pages/ExamPage';
|
||||
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
|
||||
import { ResultPage } from '../features/result/pages/ResultPage';
|
||||
import { ExamAppealsPage } from '../features/result/pages/ExamAppealsPage';
|
||||
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
|
||||
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
|
||||
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
||||
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage';
|
||||
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
|
||||
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
||||
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
|
||||
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
|
||||
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
||||
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
|
||||
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||
|
||||
@@ -47,6 +49,8 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
{ path: '/set-password', element: <SetPasswordPage /> },
|
||||
{ path: '/reset-password', element: <SetPasswordPage /> },
|
||||
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
||||
],
|
||||
},
|
||||
@@ -68,8 +72,9 @@ const router = createBrowserRouter([
|
||||
{ path: 'locations', element: <LocationPage /> },
|
||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
|
||||
{ path: 'coc-queue', element: <CoCQueuePage /> },
|
||||
{ path: 'coc-queue/:id', element: <CoCReviewPage /> },
|
||||
// CoC/CoP review happens in the config-driven licence queue.
|
||||
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
||||
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'endorsement-queue', element: <EndorsementQueuePage /> },
|
||||
{ path: 'endorsement-queue/:id', element: <EndorsementReviewPage /> },
|
||||
{ path: 'medical-verification', element: <MedicalVerificationPage /> },
|
||||
@@ -80,7 +85,8 @@ const router = createBrowserRouter([
|
||||
{ path: 'exams', element: <ExamPage /> },
|
||||
{ path: 'exams/:id', element: <ExamDetailPage /> },
|
||||
{ path: 'exam-results', element: <ResultPage /> },
|
||||
// { path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
|
||||
{ path: 'exam-appeals', element: <ExamAppealsPage /> },
|
||||
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
|
||||
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
|
||||
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
||||
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
@@ -89,6 +95,7 @@ const router = createBrowserRouter([
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
||||
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
||||
{ path: 'licence-register', element: <LicenseRegisterPage /> },
|
||||
// Deep link into the grid with the type facet pinned, so "Freight
|
||||
// Forwarder" in the nav is a filtered view rather than a page.
|
||||
{ path: 'licence-review/type/:typeCode', element: <LicenseQueuePage /> },
|
||||
@@ -103,8 +110,10 @@ const router = createBrowserRouter([
|
||||
{ path: 'joint-investment-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'mto-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'mto-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'waiver', element: <WaiverQueuePage /> },
|
||||
{ path: 'waiver/:id', element: <WaiverReviewPage /> },
|
||||
// Waivers are seeded licence types, so their queue is the licence
|
||||
// queue filtered by type and their review is the licence review.
|
||||
{ path: 'waiver', element: <Navigate to="/licence-review/type/PRE_WAIVER" replace /> },
|
||||
{ path: 'waiver/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,20 +1,278 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
useGetMySeaTimeQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
const CERTIFICATE_TYPE_KEYS = [
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
];
|
||||
|
||||
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon
|
||||
color={ok ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="xl"
|
||||
>
|
||||
{ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
* CoC / CoP home (US-CERT-002…004, 009): eligibility at a glance, the two
|
||||
* application entry points, and the seafarer's certificate applications and
|
||||
* issued certificates. The wizard itself is the config-driven licensing flow.
|
||||
*/
|
||||
export function CertificatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { profile, isLoading: loadingProfile } = useCurrentProfile();
|
||||
const { data: seaTime } = useGetMySeaTimeQuery();
|
||||
const { data: medicals } = useGetMyMedicalCertificatesQuery();
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
const registered =
|
||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const hasMedical = (medicals ?? []).some(
|
||||
(certificate) =>
|
||||
certificate.status !== 'REJECTED' && certificate.expiryDate >= today,
|
||||
);
|
||||
const verifiedDays = seaTime?.totalDays ?? 0;
|
||||
|
||||
const certificateApplications = (applications?.items ?? []).filter((app) =>
|
||||
CERTIFICATE_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||
);
|
||||
const inFlight = certificateApplications.filter(
|
||||
(app) => !TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
const issued = (licenses?.items ?? []).filter((license) =>
|
||||
CERTIFICATE_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
|
||||
);
|
||||
|
||||
async function download(licenseId: string) {
|
||||
try {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not fetch certificate'));
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingProfile || loadingApplications) {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="My certificates"
|
||||
description="Seafarer certificates are not connected to the backend yet."
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack maw={860} mx="auto">
|
||||
<Title order={2}>My Certificates</Title>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600} mb={6}>
|
||||
Eligibility
|
||||
</Text>
|
||||
<List spacing={4} size="sm">
|
||||
<EligibilityItem
|
||||
ok={registered}
|
||||
label={
|
||||
registered
|
||||
? `Registered seafarer (${profile?.seafarerNumber})`
|
||||
: 'Active seafarer registration required'
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
<EligibilityItem
|
||||
ok={hasMedical}
|
||||
label={
|
||||
hasMedical
|
||||
? 'Current medical certificate on file'
|
||||
: 'A current medical certificate is required'
|
||||
}
|
||||
/>
|
||||
<EligibilityItem
|
||||
ok={verifiedDays > 0}
|
||||
label={`Verified sea time: ${verifiedDays} days (CoC needs 360, CoP 90)`}
|
||||
/>
|
||||
</List>
|
||||
</div>
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() =>
|
||||
navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')
|
||||
}
|
||||
>
|
||||
Apply for CoC
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() =>
|
||||
navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')
|
||||
}
|
||||
>
|
||||
Apply for CoP
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
{!registered && (
|
||||
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
||||
Complete your{' '}
|
||||
<Text
|
||||
component="span"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/seafarer-registration')}
|
||||
>
|
||||
seafarer registration
|
||||
</Text>{' '}
|
||||
first — certificate applications are refused without it.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{inFlight.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Applications in progress</Title>
|
||||
{inFlight.map((app) => (
|
||||
<Card key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.licenseType?.name?.en}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Continue'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Issued certificates</Title>
|
||||
{issued.length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No certificates issued yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Certificate №</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Expires</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{issued.map((license) => (
|
||||
<Table.Tr key={license.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{license.licenseType?.name?.en}</Table.Td>
|
||||
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>{license.expiryDate?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{license.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconCertificate size={14} />}
|
||||
onClick={() => download(license.id)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
/** The CoC wizard is the config-driven licensing flow; keep the old URL alive. */
|
||||
export function CoCApplicationPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Apply for a CoC"
|
||||
description="Certificate of Competency applications are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
return <Navigate to="/licensing/CERTIFICATE_OF_COMPETENCY/apply" replace />;
|
||||
}
|
||||
|
||||
export default CoCApplicationPage;
|
||||
|
||||
406
apps/portal/src/app/features/exams/pages/ExamsPage.tsx
Normal file
406
apps/portal/src/app/features/exams/pages/ExamsPage.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconClipboardList, IconFileText, IconGavel } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
useApiQuery,
|
||||
useApiMutation,
|
||||
extractErrorMessage,
|
||||
openAuthedDocument,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
interface OpenExam {
|
||||
id: string;
|
||||
title: { en?: string; am?: string };
|
||||
date: string;
|
||||
venue: string | null;
|
||||
status: string;
|
||||
certification?: { name?: { en?: string } };
|
||||
}
|
||||
|
||||
type AttendanceStatus =
|
||||
| 'REGISTERED'
|
||||
| 'PRESENT'
|
||||
| 'ABSENT'
|
||||
| 'LATE'
|
||||
| 'WITHDRAWN'
|
||||
| 'DISQUALIFIED';
|
||||
|
||||
interface MyRegistration {
|
||||
id: string;
|
||||
admissionNumber: string;
|
||||
createdAt: string;
|
||||
kind: 'NEW' | 'RETAKE';
|
||||
attemptNumber: number;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
exam?: OpenExam;
|
||||
}
|
||||
|
||||
interface MyResult {
|
||||
id: string;
|
||||
totalScore: number;
|
||||
status: 'PASSED' | 'FAILED';
|
||||
publishedAt: string | null;
|
||||
remark?: { en?: string } | null;
|
||||
exam?: OpenExam;
|
||||
}
|
||||
|
||||
interface MyAppeal {
|
||||
id: string;
|
||||
appealNumber: string;
|
||||
status: 'SUBMITTED' | 'UNDER_REVIEW' | 'UPHELD' | 'REJECTED';
|
||||
reason: string;
|
||||
decisionRemark: string | null;
|
||||
resultId: string;
|
||||
}
|
||||
|
||||
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
REGISTERED: 'gray',
|
||||
PRESENT: 'teal',
|
||||
LATE: 'yellow',
|
||||
ABSENT: 'red',
|
||||
WITHDRAWN: 'orange',
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
/**
|
||||
* The candidate's examination home (US-EXAM-007/008/014/015/016): sessions to
|
||||
* sit, the admission slip to print, published results, and the appeal route
|
||||
* when a mark looks wrong.
|
||||
*/
|
||||
export function ExamsPage() {
|
||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||
const [appealReason, setAppealReason] = useState('');
|
||||
|
||||
const { data: open, isLoading: loadingOpen } = useApiQuery<OpenExam[]>({
|
||||
url: '/exams/open',
|
||||
method: 'GET',
|
||||
});
|
||||
const {
|
||||
data: mine,
|
||||
isLoading: loadingMine,
|
||||
refetch,
|
||||
} = useApiQuery<MyRegistration[]>({
|
||||
url: '/exams/registrations/mine',
|
||||
method: 'GET',
|
||||
});
|
||||
const { data: results, isLoading: loadingResults } = useApiQuery<MyResult[]>({
|
||||
url: '/results/mine',
|
||||
method: 'GET',
|
||||
});
|
||||
const {
|
||||
data: appeals,
|
||||
refetch: refetchAppeals,
|
||||
} = useApiQuery<MyAppeal[]>({ url: '/results/appeals/mine', method: 'GET' });
|
||||
const [registerTrigger, { isLoading: registering }] = useApiMutation();
|
||||
const [appealTrigger, { isLoading: appealing }] = useApiMutation();
|
||||
|
||||
const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id));
|
||||
|
||||
const register = async (exam: OpenExam) => {
|
||||
try {
|
||||
const result = (await registerTrigger({
|
||||
url: `/exams/${exam.id}/register`,
|
||||
method: 'POST',
|
||||
}).unwrap()) as { admissionNumber?: string };
|
||||
notify.success(
|
||||
`Registered — admission number ${result.admissionNumber ?? 'issued'}`,
|
||||
);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Could not register');
|
||||
notify.error(
|
||||
key === 'seafarer_registration_required'
|
||||
? 'An active seafarer registration is required to sit examinations.'
|
||||
: key === 'already_registered_for_exam'
|
||||
? 'You are already registered for this session.'
|
||||
: key === 'subject_already_passed'
|
||||
? 'You have already passed this subject — a resit is not needed.'
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSlip = async (registration: MyRegistration) => {
|
||||
try {
|
||||
await openAuthedDocument(
|
||||
`/exams/registrations/${registration.id}/slip`,
|
||||
`admission-${registration.admissionNumber}.pdf`,
|
||||
);
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
extractErrorMessage(error, 'Could not generate the admission slip'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const submitAppeal = async () => {
|
||||
if (!appealFor) return;
|
||||
try {
|
||||
const appeal = (await appealTrigger({
|
||||
url: `/results/${appealFor.id}/appeal`,
|
||||
method: 'POST',
|
||||
body: { reason: appealReason.trim() },
|
||||
}).unwrap()) as { appealNumber?: string };
|
||||
notify.success(`Appeal ${appeal.appealNumber ?? ''} submitted`);
|
||||
setAppealFor(null);
|
||||
setAppealReason('');
|
||||
refetchAppeals();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Could not submit the appeal');
|
||||
notify.error(
|
||||
key.startsWith('appeal_window_closed')
|
||||
? `The appeal window (${key.split(':')[1] ?? ''} days from publication) has closed.`
|
||||
: key === 'appeal_already_open'
|
||||
? 'An appeal on this result is already being considered.'
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingOpen || loadingMine || loadingResults) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack maw={900} mx="auto">
|
||||
<Title order={2}>Examinations</Title>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Open sessions</Title>
|
||||
{(open ?? []).length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No upcoming sessions are open for registration.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
(open ?? []).map((exam) => (
|
||||
<Card key={exam.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{exam.title?.en}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{exam.certification?.name?.en ?? ''} ·{' '}
|
||||
{exam.date?.slice(0, 10)}
|
||||
{exam.venue ? ` · ${exam.venue}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
{registeredExamIds.has(exam.id) ? (
|
||||
<Badge color="teal" variant="light">
|
||||
Registered
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
loading={registering}
|
||||
leftSection={<IconClipboardList size={14} />}
|
||||
onClick={() => register(exam)}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>My registrations</Title>
|
||||
{(mine ?? []).length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No exam registrations yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Admission №</Table.Th>
|
||||
<Table.Th>Examination</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th>Venue</Table.Th>
|
||||
<Table.Th>Attempt</Table.Th>
|
||||
<Table.Th>Attendance</Table.Th>
|
||||
<Table.Th>Slip</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(mine ?? []).map((registration) => (
|
||||
<Table.Tr key={registration.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{registration.admissionNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{registration.exam?.title?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{registration.exam?.date?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
>
|
||||
{registration.kind === 'RETAKE'
|
||||
? `Retake · ${registration.attemptNumber}`
|
||||
: 'First sitting'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={
|
||||
ATTENDANCE_COLOR[registration.attendanceStatus] ??
|
||||
'gray'
|
||||
}
|
||||
>
|
||||
{registration.attendanceStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconFileText size={13} />}
|
||||
onClick={() => downloadSlip(registration)}
|
||||
>
|
||||
Slip
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>My results</Title>
|
||||
{(results ?? []).length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No results have been published yet. Marks appear here once the
|
||||
authority approves and publishes them.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Examination</Table.Th>
|
||||
<Table.Th>Published</Table.Th>
|
||||
<Table.Th>Score</Table.Th>
|
||||
<Table.Th>Outcome</Table.Th>
|
||||
<Table.Th>Appeal</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(results ?? []).map((result) => {
|
||||
const appeal = (appeals ?? []).find(
|
||||
(a) => a.resultId === result.id,
|
||||
);
|
||||
return (
|
||||
<Table.Tr key={result.id}>
|
||||
<Table.Td>{result.exam?.title?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{result.publishedAt?.slice(0, 10) ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{result.totalScore}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={result.status === 'PASSED' ? 'teal' : 'red'}
|
||||
>
|
||||
{result.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{appeal ? (
|
||||
<Badge size="sm" variant="light" color="grape">
|
||||
{appeal.appealNumber} · {appeal.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<IconGavel size={13} />}
|
||||
onClick={() => setAppealFor(result)}
|
||||
>
|
||||
Appeal
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(appealFor)}
|
||||
onClose={() => setAppealFor(null)}
|
||||
title="Request a review of this result"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Explain what you believe went wrong with the marking or the
|
||||
administration of {appealFor?.exam?.title?.en ?? 'this examination'}.
|
||||
Appeals must be lodged within 14 days of publication.
|
||||
</Text>
|
||||
<Textarea
|
||||
minRows={4}
|
||||
autosize
|
||||
label="Grounds for appeal"
|
||||
value={appealReason}
|
||||
onChange={(event) => setAppealReason(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setAppealFor(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={appealing}
|
||||
disabled={appealReason.trim().length === 0}
|
||||
onClick={submitAppeal}
|
||||
>
|
||||
Submit appeal
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExamsPage;
|
||||
@@ -41,6 +41,9 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
|
||||
CARGO_FREIGHT: IconBuildingWarehouse,
|
||||
SHIPPING_AGENCY: IconShip,
|
||||
INVESTMENT: IconTrendingUp,
|
||||
// Filtered out of this catalogue (requiresOperatorMode is false), listed
|
||||
// only so the record stays total if that ever changes.
|
||||
MARITIME_PERSONNEL: IconShip,
|
||||
};
|
||||
|
||||
function formatFee(amount: string | number | null, currency: string): string {
|
||||
@@ -69,6 +72,10 @@ export function LicenseCatalogue() {
|
||||
const { groups, orphans } = useMemo(() => {
|
||||
const active = (types?.items ?? [])
|
||||
.filter((t) => t.isActive)
|
||||
// Person-centric registrations (seafarer) are not operator licences:
|
||||
// they can never be declared as a mode, have their own entry points,
|
||||
// and would only confuse this catalogue — even under "show all".
|
||||
.filter((t) => t.requiresOperatorMode !== false)
|
||||
// Only what the applicant operates as. The server enforces the same rule
|
||||
// on create; this is what stops them starting an application they will
|
||||
// be refused at the end of.
|
||||
|
||||
@@ -134,7 +134,10 @@ export function LicenseApplicationPage() {
|
||||
// Sections that share a group collapse onto one step, so the stepper stays
|
||||
// short instead of showing a page per section.
|
||||
const steps = useMemo(
|
||||
() => buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft),
|
||||
() =>
|
||||
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
||||
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||
}),
|
||||
[config, draft],
|
||||
);
|
||||
const sections = useMemo(
|
||||
|
||||
@@ -4,21 +4,70 @@ import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Sends an applicant who has not said what they operate as to the step that
|
||||
* asks. Everything else in the portal is keyed off that answer — the catalogue
|
||||
* offers nothing without it, and the server refuses an application for a mode
|
||||
* the profile does not hold — so it is asked once, up front.
|
||||
* asks. The logistics licence catalogue is keyed off that answer — it offers
|
||||
* nothing without it, and the server refuses an application for a mode the
|
||||
* profile does not hold — so it is asked once, up front.
|
||||
*
|
||||
* Deliberately not a hard gate on every route: the profile and support pages
|
||||
* stay reachable, because someone who cannot answer the question yet must
|
||||
* still be able to reach their account and ask for help.
|
||||
* The gate is deliberately not portal-wide. Seafarer and vessel services are
|
||||
* seeded `requiresOperatorMode: false` precisely because a seafarer holds no
|
||||
* logistics mode of operation; gating them on this question locked the people
|
||||
* those services exist for out of registration, sea records, certificates and
|
||||
* examinations. Profile and support stay open for the same reason: someone who
|
||||
* cannot answer the question yet must still reach their account and ask for
|
||||
* help.
|
||||
*/
|
||||
const ALWAYS_ALLOWED = ['/onboarding/operations', '/profile', '/support'];
|
||||
const ALWAYS_ALLOWED = [
|
||||
'/onboarding/operations',
|
||||
'/profile',
|
||||
'/support',
|
||||
'/notifications',
|
||||
// Seafarer services — no operator mode required by the licence types behind
|
||||
// them (SEAFARER_REGISTRATION, CERTIFICATE_OF_COMPETENCY/PROFICIENCY).
|
||||
'/seafarer-registration',
|
||||
'/seafarer/records',
|
||||
'/seafarer-registry',
|
||||
'/exams',
|
||||
'/certificates',
|
||||
'/seaman-book',
|
||||
'/endorsements',
|
||||
'/documents',
|
||||
// Vessel services — VESSEL_REGISTRATION is likewise mode-free.
|
||||
'/vessel-registration',
|
||||
// Waivers are filed by importers, who hold no logistics operating licence.
|
||||
'/waiver',
|
||||
];
|
||||
|
||||
/**
|
||||
* Licence types seeded `requiresOperatorMode: false`.
|
||||
*
|
||||
* The wizard lives at one shared route (`/licensing/:typeCode/apply`), so the
|
||||
* gate has to know which types behind it are mode-free — otherwise a seafarer
|
||||
* or an importer reaches their service page and is bounced the moment they
|
||||
* start the application. Mirrors the seeds; the server is still the authority,
|
||||
* refusing a create for a mode the profile does not hold.
|
||||
*/
|
||||
const MODE_FREE_TYPE_KEYS = [
|
||||
'SEAFARER_REGISTRATION',
|
||||
'VESSEL_REGISTRATION',
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'PRE_WAIVER',
|
||||
'POST_WAIVER',
|
||||
];
|
||||
|
||||
function isModeFreeLicensingRoute(pathname: string): boolean {
|
||||
const match = pathname.match(/^\/licensing\/([^/]+)/);
|
||||
return match ? MODE_FREE_TYPE_KEYS.includes(match[1]) : false;
|
||||
}
|
||||
|
||||
export function RequireOperations({ children }: { children: React.ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery();
|
||||
|
||||
if (ALWAYS_ALLOWED.some((path) => pathname.startsWith(path))) {
|
||||
if (
|
||||
ALWAYS_ALLOWED.some((path) => pathname.startsWith(path)) ||
|
||||
isModeFreeLicensingRoute(pathname)
|
||||
) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,12 @@ export function OperationsFormContent({
|
||||
useEffect(() => setSelected(declaredIds), [declaredIds]);
|
||||
|
||||
const options = useMemo(
|
||||
() => (catalogue?.items ?? []).filter((t) => t.isActive),
|
||||
() =>
|
||||
(catalogue?.items ?? [])
|
||||
.filter((t) => t.isActive)
|
||||
// A mode of operation is an operator licence; person-centric
|
||||
// registrations (seafarer) cannot be declared as one.
|
||||
.filter((t) => t.requiresOperatorMode !== false),
|
||||
[catalogue],
|
||||
);
|
||||
|
||||
|
||||
765
apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx
Normal file
765
apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx
Normal file
@@ -0,0 +1,765 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconEdit,
|
||||
IconFileUpload,
|
||||
IconInfoCircle,
|
||||
IconPaperclip,
|
||||
IconPlus,
|
||||
IconStethoscope,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
uploadDocument,
|
||||
useCreateMedicalCertificateMutation,
|
||||
useCreateSeaServiceRecordMutation,
|
||||
useDeleteMedicalCertificateMutation,
|
||||
useDeleteSeaServiceRecordMutation,
|
||||
useGetAttachmentsQuery,
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
useGetMySeaServiceRecordsQuery,
|
||||
useGetMySeaTimeQuery,
|
||||
useUpdateMedicalCertificateMutation,
|
||||
useUpdateSeaServiceRecordMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||
|
||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
const FITNESS_OPTIONS = [
|
||||
{ value: 'FIT', label: 'Fit' },
|
||||
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' },
|
||||
{ value: 'UNFIT', label: 'Unfit' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Evidence viewer/uploader shared by both record kinds.
|
||||
*
|
||||
* Files upload against the record itself (`SEA_SERVICE_RECORD` /
|
||||
* `MEDICAL_CERTIFICATE` owner types), so the officer verifying it later opens
|
||||
* exactly what the seafarer attached.
|
||||
*/
|
||||
function EvidenceModal({
|
||||
ownerType,
|
||||
ownerId,
|
||||
onClose,
|
||||
}: {
|
||||
ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE';
|
||||
ownerId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery(
|
||||
{ ownerType, ownerId: ownerId ?? '' },
|
||||
{ skip: !ownerId },
|
||||
);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const upload = async (file: File | null) => {
|
||||
if (!file || !ownerId) return;
|
||||
setUploading(true);
|
||||
const result = await uploadDocument({
|
||||
ownerType,
|
||||
ownerId,
|
||||
documentKey: 'evidence',
|
||||
file,
|
||||
});
|
||||
setUploading(false);
|
||||
if (result.ok) {
|
||||
notify.success('Evidence uploaded');
|
||||
refetch();
|
||||
} else {
|
||||
notify.error(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const files = (attachments ?? []).flatMap((a) => a.files);
|
||||
|
||||
return (
|
||||
<Modal opened={Boolean(ownerId)} onClose={onClose} title="Evidence" centered>
|
||||
<Stack>
|
||||
{isLoading ? (
|
||||
<Loader size="sm" />
|
||||
) : files.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No evidence uploaded yet.
|
||||
</Text>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<Group key={file.id} gap="xs">
|
||||
<IconPaperclip size={16} />
|
||||
{file.url ? (
|
||||
<Anchor href={file.url} target="_blank" size="sm">
|
||||
{file.originalName}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text size="sm">{file.originalName}</Text>
|
||||
)}
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
<FileButton onChange={upload} accept="image/*,application/pdf">
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
loading={uploading}
|
||||
leftSection={<IconFileUpload size={16} />}
|
||||
>
|
||||
Upload evidence
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sea service
|
||||
|
||||
const EMPTY_SEA_SERVICE = {
|
||||
vesselName: '',
|
||||
imoNumber: '',
|
||||
vesselType: '',
|
||||
flagState: '',
|
||||
rank: '',
|
||||
engagementDate: '',
|
||||
dischargeDate: '',
|
||||
dutiesDescription: '',
|
||||
};
|
||||
|
||||
function SeaServiceTab() {
|
||||
const { data: records, isLoading } = useGetMySeaServiceRecordsQuery();
|
||||
const { data: seaTime } = useGetMySeaTimeQuery();
|
||||
const [createRecord, { isLoading: creating }] =
|
||||
useCreateSeaServiceRecordMutation();
|
||||
const [updateRecord, { isLoading: updating }] =
|
||||
useUpdateSeaServiceRecordMutation();
|
||||
const [deleteRecord] = useDeleteSeaServiceRecordMutation();
|
||||
|
||||
const [editing, setEditing] = useState<SeaServiceRecord | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
|
||||
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
|
||||
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_SEA_SERVICE);
|
||||
setGrossTonnage('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: SeaServiceRecord) => {
|
||||
setEditing(record);
|
||||
setForm({
|
||||
vesselName: record.vesselName,
|
||||
imoNumber: record.imoNumber ?? '',
|
||||
vesselType: record.vesselType ?? '',
|
||||
flagState: record.flagState ?? '',
|
||||
rank: record.rank,
|
||||
engagementDate: record.engagementDate,
|
||||
dischargeDate: record.dischargeDate,
|
||||
dutiesDescription: record.dutiesDescription ?? '',
|
||||
});
|
||||
setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const body = {
|
||||
vesselName: form.vesselName,
|
||||
rank: form.rank,
|
||||
engagementDate: form.engagementDate,
|
||||
dischargeDate: form.dischargeDate,
|
||||
...(form.imoNumber ? { imoNumber: form.imoNumber } : {}),
|
||||
...(form.vesselType ? { vesselType: form.vesselType } : {}),
|
||||
...(form.flagState ? { flagState: form.flagState } : {}),
|
||||
...(form.dutiesDescription
|
||||
? { dutiesDescription: form.dutiesDescription }
|
||||
: {}),
|
||||
...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}),
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
await updateRecord({ id: editing.id, body }).unwrap();
|
||||
notify.success('Sea-service record updated');
|
||||
} else {
|
||||
await createRecord(body).unwrap();
|
||||
notify.success('Sea-service record added');
|
||||
}
|
||||
setModalOpen(false);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not save the record'));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (record: SeaServiceRecord) => {
|
||||
try {
|
||||
await deleteRecord(record.id).unwrap();
|
||||
notify.success('Record withdrawn');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not delete the record'));
|
||||
}
|
||||
};
|
||||
|
||||
const valid =
|
||||
form.vesselName.trim().length > 1 &&
|
||||
form.rank.trim().length > 1 &&
|
||||
form.engagementDate &&
|
||||
form.dischargeDate &&
|
||||
form.engagementDate < form.dischargeDate;
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Every engagement aboard a vessel, with its evidence. Verified
|
||||
records feed certificate eligibility.
|
||||
</Text>
|
||||
{seaTime && seaTime.verifiedRecords > 0 && (
|
||||
<Badge variant="light" color="teal">
|
||||
Approved sea time: {seaTime.totalDays} days
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
||||
Add sea service
|
||||
</Button>
|
||||
</Group>
|
||||
{(records ?? []).length === 0 ? (
|
||||
<Paper withBorder p="xl" radius="md">
|
||||
<Text c="dimmed" ta="center">
|
||||
No sea-service records yet.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>From</Table.Th>
|
||||
<Table.Th>To</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(records ?? []).map((record) => {
|
||||
const locked = record.status !== 'SUBMITTED';
|
||||
return (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{record.vesselName}
|
||||
</Text>
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>{record.engagementDate}</Table.Td>
|
||||
<Table.Td>{record.dischargeDate}</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
label={record.verificationRemark ?? ''}
|
||||
disabled={!record.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="Evidence">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => setEvidenceFor(record.id)}
|
||||
>
|
||||
<IconPaperclip size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={locked ? 'Verified records are frozen' : 'Edit'}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={locked}
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={locked ? 'Verified records are frozen' : 'Delete'}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => remove(record)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Edit sea service' : 'Add sea service'}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Vessel name"
|
||||
required
|
||||
value={form.vesselName}
|
||||
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="IMO number"
|
||||
value={form.imoNumber}
|
||||
onChange={(e) => setForm({ ...form, imoNumber: e.target.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Vessel type"
|
||||
value={form.vesselType}
|
||||
onChange={(e) => setForm({ ...form, vesselType: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Flag state"
|
||||
value={form.flagState}
|
||||
onChange={(e) => setForm({ ...form, flagState: e.target.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Gross tonnage"
|
||||
min={0}
|
||||
value={grossTonnage}
|
||||
onChange={(v) => setGrossTonnage(typeof v === 'number' ? v : '')}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Rank / capacity"
|
||||
required
|
||||
value={form.rank}
|
||||
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
type="date"
|
||||
label="Engagement date"
|
||||
required
|
||||
value={form.engagementDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, engagementDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
label="Discharge date"
|
||||
required
|
||||
value={form.dischargeDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, dischargeDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Textarea
|
||||
label="Duties"
|
||||
value={form.dutiesDescription}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, dutiesDescription: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={!valid}
|
||||
loading={creating || updating}
|
||||
>
|
||||
{editing ? 'Save changes' : 'Add record'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<EvidenceModal
|
||||
ownerType="SEA_SERVICE_RECORD"
|
||||
ownerId={evidenceFor}
|
||||
onClose={() => setEvidenceFor(null)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- medical
|
||||
|
||||
const EMPTY_MEDICAL = {
|
||||
issuerName: '',
|
||||
certificateNumber: '',
|
||||
issueDate: '',
|
||||
expiryDate: '',
|
||||
fitnessStatus: 'FIT',
|
||||
restrictions: '',
|
||||
};
|
||||
|
||||
function MedicalTab() {
|
||||
const { data: certificates, isLoading } = useGetMyMedicalCertificatesQuery();
|
||||
const [createCertificate, { isLoading: creating }] =
|
||||
useCreateMedicalCertificateMutation();
|
||||
const [updateCertificate, { isLoading: updating }] =
|
||||
useUpdateMedicalCertificateMutation();
|
||||
const [deleteCertificate] = useDeleteMedicalCertificateMutation();
|
||||
|
||||
const [editing, setEditing] = useState<MedicalCertificate | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
|
||||
const [form, setForm] = useState(EMPTY_MEDICAL);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_MEDICAL);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (certificate: MedicalCertificate) => {
|
||||
setEditing(certificate);
|
||||
setForm({
|
||||
issuerName: certificate.issuerName,
|
||||
certificateNumber: certificate.certificateNumber ?? '',
|
||||
issueDate: certificate.issueDate,
|
||||
expiryDate: certificate.expiryDate,
|
||||
fitnessStatus: certificate.fitnessStatus,
|
||||
restrictions: certificate.restrictions ?? '',
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const body = {
|
||||
issuerName: form.issuerName,
|
||||
issueDate: form.issueDate,
|
||||
expiryDate: form.expiryDate,
|
||||
fitnessStatus: form.fitnessStatus as MedicalCertificate['fitnessStatus'],
|
||||
...(form.certificateNumber
|
||||
? { certificateNumber: form.certificateNumber }
|
||||
: {}),
|
||||
...(form.restrictions ? { restrictions: form.restrictions } : {}),
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
await updateCertificate({ id: editing.id, body }).unwrap();
|
||||
notify.success('Medical certificate updated');
|
||||
} else {
|
||||
await createCertificate(body).unwrap();
|
||||
notify.success('Medical certificate added');
|
||||
}
|
||||
setModalOpen(false);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not save the certificate'));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (certificate: MedicalCertificate) => {
|
||||
try {
|
||||
await deleteCertificate(certificate.id).unwrap();
|
||||
notify.success('Certificate withdrawn');
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
extractErrorMessage(error, 'Could not delete the certificate'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const valid =
|
||||
form.issuerName.trim().length > 1 &&
|
||||
form.issueDate &&
|
||||
form.expiryDate &&
|
||||
form.issueDate < form.expiryDate;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
STCW medical fitness certificates. An expired certificate blocks new
|
||||
applications that require one.
|
||||
</Text>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
||||
Add certificate
|
||||
</Button>
|
||||
</Group>
|
||||
{(certificates ?? []).length === 0 ? (
|
||||
<Paper withBorder p="xl" radius="md">
|
||||
<Text c="dimmed" ta="center">
|
||||
No medical certificates yet.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Expires</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(certificates ?? []).map((certificate) => {
|
||||
const locked = certificate.status !== 'SUBMITTED';
|
||||
const expired = certificate.expiryDate < today;
|
||||
return (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{certificate.issuerName}
|
||||
</Text>
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{certificate.issueDate}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{certificate.expiryDate}
|
||||
{expired && <Badge color="red">Expired</Badge>}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{FITNESS_OPTIONS.find(
|
||||
(o) => o.value === certificate.fitnessStatus,
|
||||
)?.label ?? certificate.fitnessStatus}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
label={certificate.verificationRemark ?? ''}
|
||||
disabled={!certificate.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[certificate.status]}>
|
||||
{certificate.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="Scan / evidence">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => setEvidenceFor(certificate.id)}
|
||||
>
|
||||
<IconPaperclip size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
locked ? 'Verified certificates are frozen' : 'Edit'
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={locked}
|
||||
onClick={() => openEdit(certificate)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
locked
|
||||
? 'Verified certificates are frozen'
|
||||
: 'Delete'
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => remove(certificate)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Edit medical certificate' : 'Add medical certificate'}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Issuing clinic / physician"
|
||||
required
|
||||
value={form.issuerName}
|
||||
onChange={(e) => setForm({ ...form, issuerName: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Certificate number"
|
||||
value={form.certificateNumber}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, certificateNumber: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
type="date"
|
||||
label="Issue date"
|
||||
required
|
||||
value={form.issueDate}
|
||||
onChange={(e) => setForm({ ...form, issueDate: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
label="Expiry date"
|
||||
required
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => setForm({ ...form, expiryDate: e.target.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
label="Fitness outcome"
|
||||
data={FITNESS_OPTIONS}
|
||||
value={form.fitnessStatus}
|
||||
onChange={(v) => setForm({ ...form, fitnessStatus: v ?? 'FIT' })}
|
||||
/>
|
||||
{form.fitnessStatus === 'FIT_WITH_RESTRICTIONS' && (
|
||||
<Textarea
|
||||
label="Restrictions"
|
||||
value={form.restrictions}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, restrictions: e.target.value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={!valid}
|
||||
loading={creating || updating}
|
||||
>
|
||||
{editing ? 'Save changes' : 'Add certificate'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<EvidenceModal
|
||||
ownerType="MEDICAL_CERTIFICATE"
|
||||
ownerId={evidenceFor}
|
||||
onClose={() => setEvidenceFor(null)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The seafarer's evidence shelf (US-SSM-001/006): sea-service history and
|
||||
* medical certificates, each with uploaded evidence, editable until an
|
||||
* officer verifies them.
|
||||
*/
|
||||
export function MySeaRecordsPage() {
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>My Sea Records</Title>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
Records you add here are submitted for EMA verification. Once verified
|
||||
they are frozen and count toward certificate eligibility.
|
||||
</Alert>
|
||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
Sea Service
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
Medical Certificates
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
<SeaServiceTab />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
<MedicalTab />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,716 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AmharicDatePicker } from '@ema-platform/ui';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowLeft,
|
||||
IconBook,
|
||||
IconBriefcase,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconEdit,
|
||||
IconFileText,
|
||||
IconHeartbeat,
|
||||
IconHistory,
|
||||
IconLayoutDashboard,
|
||||
IconPlus,
|
||||
IconPrinter,
|
||||
IconShip,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { CountrySelect, notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
|
||||
import type { Seafarer } from './SeafarerRegistryPage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extended profile types
|
||||
// ---------------------------------------------------------------------------
|
||||
interface TrainingRecord {
|
||||
id: string;
|
||||
course: string;
|
||||
institution: string;
|
||||
certNo: string;
|
||||
issueDate: string;
|
||||
expiry: string;
|
||||
status: 'Approved' | 'Pending' | 'Expired';
|
||||
}
|
||||
|
||||
interface MedicalRecord {
|
||||
id: string;
|
||||
examType: string;
|
||||
issuedBy: string;
|
||||
issueDate: string;
|
||||
expiry: string;
|
||||
result: 'Fit' | 'Unfit' | 'Conditional';
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
interface SeaServiceRecord {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
vesselType: string;
|
||||
rank: string;
|
||||
flag: string;
|
||||
from: string;
|
||||
to: string;
|
||||
engagementPort: string;
|
||||
}
|
||||
|
||||
interface CertificationRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
certNo: string;
|
||||
issuedBy: string;
|
||||
issueDate: string;
|
||||
expiry: string;
|
||||
type: string;
|
||||
status: 'Valid' | 'Expired' | 'Pending';
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
performedBy: string;
|
||||
date: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface SeafarerProfile extends Seafarer {
|
||||
dob: string;
|
||||
nationalId: string;
|
||||
passportNo: string;
|
||||
bookNumber: string;
|
||||
permanentAddress: string;
|
||||
training: TrainingRecord[];
|
||||
medical: MedicalRecord[];
|
||||
seaService: SeaServiceRecord[];
|
||||
certifications: CertificationRecord[];
|
||||
history: HistoryEntry[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dummy API — replace bodies with real fetch calls
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchSeafarerProfile(id: string): Promise<SeafarerProfile> {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
return {
|
||||
id,
|
||||
seafarerId: 'SF-2024-0001',
|
||||
firstName: 'Abebe',
|
||||
lastName: 'Girma',
|
||||
email: 'abebe.g@email.com',
|
||||
gender: 'Male',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 911 234 567',
|
||||
region: 'Addis Ababa',
|
||||
registeredAt: '2024-01-10',
|
||||
medicalStatus: 'Fit',
|
||||
bookStatus: 'Active',
|
||||
status: 'Active',
|
||||
dob: '1988-03-15',
|
||||
nationalId: 'ET-1234567',
|
||||
passportNo: 'EP123456',
|
||||
bookNumber: 'SB-2024-0001',
|
||||
permanentAddress: 'Bole Sub-City, Woreda 03, House No. 456, Addis Ababa',
|
||||
training: [
|
||||
{ id: '1', course: 'Personal Survival Techniques', institution: 'Ethiopian Maritime Institute', certNo: 'PST-2023-0456', issueDate: '2023-01-10', expiry: '2028-01-14', status: 'Approved' },
|
||||
{ id: '2', course: 'Fire Prevention and Fire Fighting', institution: 'Djibouti Maritime Academy', certNo: 'FFF-2023-0789', issueDate: '2023-03-05', expiry: '2028-03-07', status: 'Approved' },
|
||||
{ id: '3', course: 'Elementary First Aid', institution: 'Ethiopian Maritime Institute', certNo: 'EFA-2023-0102', issueDate: '2023-01-10', expiry: '2028-01-10', status: 'Approved' },
|
||||
],
|
||||
medical: [
|
||||
{ id: '1', examType: 'STCW Medical Certificate', issuedBy: 'EMA Medical Center', issueDate: '2023-06-15', expiry: '2025-06-15', result: 'Fit', remarks: 'No medical conditions noted.' },
|
||||
{ id: '2', examType: 'Pre-Employment Medical', issuedBy: 'Addis Ababa General Hospital', issueDate: '2022-01-10', expiry: '2024-01-10', result: 'Fit', remarks: 'All tests within normal range.' },
|
||||
],
|
||||
seaService: [
|
||||
{ id: '1', vesselName: 'MV Ethiopian Star', vesselType: 'Bulk Carrier', rank: 'Ordinary Seaman', flag: 'Ethiopia', from: '2022-03-01', to: '2023-02-28', engagementPort: 'Djibouti' },
|
||||
{ id: '2', vesselName: 'MV Red Sea Express', vesselType: 'Container Ship', rank: 'Able Seaman', flag: 'Djibouti', from: '2023-04-01', to: '2024-03-31', engagementPort: 'Berbera' },
|
||||
],
|
||||
certifications: [
|
||||
{ id: '1', name: 'STCW Basic Safety Training', certNo: 'BST-2023-0001', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-01-15', expiry: '2028-01-15', type: 'STCW', status: 'Valid' },
|
||||
{ id: '2', name: 'Certificate of Competency — Deck Rating', certNo: 'COC-2023-0234', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-07-01', expiry: '2028-07-01', type: 'COC', status: 'Valid' },
|
||||
],
|
||||
history: [
|
||||
{ id: '1', action: 'Profile Created', performedBy: 'System', date: '2024-01-10', notes: 'Initial registration submitted.' },
|
||||
{ id: '2', action: 'Status → Active', performedBy: 'Admin Officer', date: '2024-01-15', notes: 'All documents verified and approved.' },
|
||||
{ id: '3', action: 'Training Record Added', performedBy: 'Abebe Girma', date: '2024-02-20', notes: 'PST certificate uploaded.' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function updateSeafarerStatus(_id: string, _status: string): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Active: 'teal', Pending: 'yellow', Suspended: 'red',
|
||||
Approved: 'teal', Expired: 'red', Valid: 'teal',
|
||||
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
|
||||
};
|
||||
|
||||
function Chip({ value }: { value: string }) {
|
||||
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} fz="sm">{title}</Text>
|
||||
{action}
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Overview
|
||||
// ---------------------------------------------------------------------------
|
||||
function OverviewTab({ profile, onStatusChange }: { profile: SeafarerProfile; onStatusChange: (s: 'Active' | 'Suspended') => void }) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<SectionCard title="Personal Information">
|
||||
<SimpleGrid cols={3} spacing="md">
|
||||
<InfoField label="Seafarer ID" value={profile.seafarerId} />
|
||||
<InfoField label="First Name" value={profile.firstName} />
|
||||
<InfoField label="Last Name" value={profile.lastName} />
|
||||
<InfoField label="Gender" value={profile.gender} />
|
||||
<InfoField label="Date of Birth" value={profile.dob} />
|
||||
<InfoField label="Nationality" value={profile.nationality} />
|
||||
<InfoField label="National ID" value={profile.nationalId} />
|
||||
<InfoField label="Passport No." value={profile.passportNo} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Contact & Status">
|
||||
<SimpleGrid cols={3} spacing="md" mb="md">
|
||||
<InfoField label="Mobile" value={profile.mobile} />
|
||||
<InfoField label="Email" value={profile.email} />
|
||||
<InfoField label="Region" value={profile.region} />
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Reg. Status</Text>
|
||||
<Chip value={profile.status} />
|
||||
</div>
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Medical Status</Text>
|
||||
<Chip value={profile.medicalStatus} />
|
||||
</div>
|
||||
<InfoField label="Book Number" value={profile.bookNumber} />
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Book Status</Text>
|
||||
<Chip value={profile.bookStatus} />
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<Group gap="xs">
|
||||
{profile.status !== 'Active' && (
|
||||
<Button size="xs" color="teal" leftSection={<IconCheck size={13} />} onClick={() => onStatusChange('Active')}>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{profile.status !== 'Suspended' && (
|
||||
<Button size="xs" color="red" variant="light" leftSection={<IconX size={13} />} onClick={() => onStatusChange('Suspended')}>
|
||||
Suspend
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" variant="default" leftSection={<IconFileText size={13} />} onClick={() => notify.info('Documents — coming soon.')}>
|
||||
Documents
|
||||
</Button>
|
||||
</Group>
|
||||
</SectionCard>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="xs">Permanent Address</Text>
|
||||
<Text fz="sm" c="dimmed">{profile.permanentAddress || '—'}</Text>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Training
|
||||
// ---------------------------------------------------------------------------
|
||||
function TrainingTab({ records, onAdd }: { records: TrainingRecord[]; onAdd: () => void }) {
|
||||
return (
|
||||
<Paper withBorder radius="md">
|
||||
<Group justify="space-between" p="md">
|
||||
<Text fw={700} fz="sm">Training Records</Text>
|
||||
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Training</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Course', 'Institution', 'Cert. No.', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Text fw={500} fz="sm">{r.course}</Text></Table.Td>
|
||||
<Table.Td>{r.institution}</Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
|
||||
<Table.Td>{r.issueDate}</Table.Td>
|
||||
<Table.Td>{r.expiry}</Table.Td>
|
||||
<Table.Td><Chip value={r.status} /></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => notify.info('View training — coming soon.')}>View</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No training records found.</Text>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Medical
|
||||
// ---------------------------------------------------------------------------
|
||||
function MedicalTab({ records, onAdd }: { records: MedicalRecord[]; onAdd: () => void }) {
|
||||
return (
|
||||
<Paper withBorder radius="md">
|
||||
<Group justify="space-between" p="md">
|
||||
<Text fw={700} fz="sm">Medical Records</Text>
|
||||
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Record</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Exam Type', 'Issued By', 'Issue Date', 'Expiry', 'Result', 'Remarks', 'Actions'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Text fw={500} fz="sm">{r.examType}</Text></Table.Td>
|
||||
<Table.Td>{r.issuedBy}</Table.Td>
|
||||
<Table.Td>{r.issueDate}</Table.Td>
|
||||
<Table.Td>{r.expiry}</Table.Td>
|
||||
<Table.Td><Chip value={r.result} /></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="dimmed" style={{ maxWidth: rem(180) }} lineClamp={1}>{r.remarks}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => notify.info('View medical record — coming soon.')}>View</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No medical records found.</Text>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Sea Service
|
||||
// ---------------------------------------------------------------------------
|
||||
function SeaServiceTab({ records, onAdd }: { records: SeaServiceRecord[]; onAdd: () => void }) {
|
||||
return (
|
||||
<Paper withBorder radius="md">
|
||||
<Group justify="space-between" p="md">
|
||||
<Text fw={700} fz="sm">Sea Service Records</Text>
|
||||
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Service</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Vessel Name', 'Type', 'Rank', 'Flag', 'From', 'To', 'Engagement Port', 'Actions'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Text fw={500} fz="sm">{r.vesselName}</Text></Table.Td>
|
||||
<Table.Td>{r.vesselType}</Table.Td>
|
||||
<Table.Td>{r.rank}</Table.Td>
|
||||
<Table.Td>{r.flag}</Table.Td>
|
||||
<Table.Td>{r.from}</Table.Td>
|
||||
<Table.Td>{r.to}</Table.Td>
|
||||
<Table.Td>{r.engagementPort}</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => notify.info('View sea service — coming soon.')}>View</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No sea service records found.</Text>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Certifications
|
||||
// ---------------------------------------------------------------------------
|
||||
function CertificationsTab({ records, onAdd }: { records: CertificationRecord[]; onAdd: () => void }) {
|
||||
return (
|
||||
<Paper withBorder radius="md">
|
||||
<Group justify="space-between" p="md">
|
||||
<Text fw={700} fz="sm">Certifications</Text>
|
||||
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Certification</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate', 'Cert. No.', 'Type', 'Issued By', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Text fw={500} fz="sm">{r.name}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
|
||||
<Table.Td><Badge variant="outline" size="xs" radius="sm">{r.type}</Badge></Table.Td>
|
||||
<Table.Td>{r.issuedBy}</Table.Td>
|
||||
<Table.Td>{r.issueDate}</Table.Td>
|
||||
<Table.Td>{r.expiry}</Table.Td>
|
||||
<Table.Td><Chip value={r.status} /></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => notify.info('View certificate — coming soon.')}>View</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No certifications found.</Text>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: History
|
||||
// ---------------------------------------------------------------------------
|
||||
function HistoryTab({ entries }: { entries: HistoryEntry[] }) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="md">Activity History</Text>
|
||||
<Divider mb="md" />
|
||||
<Stack gap="sm">
|
||||
{entries.map((e) => (
|
||||
<Group key={e.id} gap="md" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon variant="light" color="blue" size={32} radius="xl" style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<IconClock size={15} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fz="sm" fw={600}>{e.action}</Text>
|
||||
<Text fz="xs" c="dimmed">by {e.performedBy}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{e.date}</Text>
|
||||
{e.notes && <Text fz="xs" mt={2}>{e.notes}</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
{entries.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No history found.</Text>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add Record Modal (generic)
|
||||
// ---------------------------------------------------------------------------
|
||||
function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Add Training Record" size="lg">
|
||||
<Stack gap="sm">
|
||||
<TextInput label="Course Name" placeholder="e.g. Personal Survival Techniques" required />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Institution" placeholder="Training institution" />
|
||||
<TextInput label="Certificate No." placeholder="CERT-0000" />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<AmharicDatePicker label="Issue Date" value={issueDate} onChange={setIssueDate} />
|
||||
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} />
|
||||
</SimpleGrid>
|
||||
<Select label="Status" data={['Approved', 'Pending', 'Expired']} defaultValue="Pending" />
|
||||
<ModalFooter mt="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={() => { notify.success('Training record added (demo).'); onClose(); }}>Save</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Add Medical Record" size="lg">
|
||||
<Stack gap="sm">
|
||||
<TextInput label="Exam Type" placeholder="e.g. STCW Medical Certificate" required />
|
||||
<TextInput label="Issued By" placeholder="Issuing authority" />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<AmharicDatePicker label="Issue Date" value={issueDate} onChange={setIssueDate} />
|
||||
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} />
|
||||
</SimpleGrid>
|
||||
<Select label="Result" data={['Fit', 'Unfit', 'Conditional']} defaultValue="Fit" />
|
||||
<Textarea label="Remarks" placeholder="Any notes" autosize minRows={2} />
|
||||
<ModalFooter mt="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={() => { notify.success('Medical record added (demo).'); onClose(); }}>Save</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const [flag, setFlag] = useState<string | null>(null);
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [toDate, setToDate] = useState('');
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Add Sea Service Record" size="lg">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Vessel Name" placeholder="MV Name" required />
|
||||
<TextInput label="Vessel Type" placeholder="e.g. Bulk Carrier" />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Rank" placeholder="e.g. Able Seaman" />
|
||||
<CountrySelect label="Flag" value={flag} onChange={setFlag} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<AmharicDatePicker label="From" value={fromDate} onChange={setFromDate} />
|
||||
<AmharicDatePicker label="To" value={toDate} onChange={setToDate} />
|
||||
</SimpleGrid>
|
||||
<TextInput label="Engagement Port" placeholder="Port name" />
|
||||
<ModalFooter mt="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={() => { notify.success('Sea service record added (demo).'); onClose(); }}>Save</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddCertModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Add Certification" size="lg">
|
||||
<Stack gap="sm">
|
||||
<TextInput label="Certificate Name" placeholder="e.g. STCW Basic Safety Training" required />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Certificate No." placeholder="CERT-0000" />
|
||||
<Select label="Type" data={['STCW', 'COC', 'COE', 'GMDSS', 'Other']} placeholder="Select type" />
|
||||
</SimpleGrid>
|
||||
<TextInput label="Issued By" placeholder="Issuing authority" />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<AmharicDatePicker label="Issue Date" value={issueDate} onChange={setIssueDate} />
|
||||
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} />
|
||||
</SimpleGrid>
|
||||
<Select label="Status" data={['Valid', 'Expired', 'Pending']} defaultValue="Valid" />
|
||||
<ModalFooter mt="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={() => { notify.success('Certification added (demo).'); onClose(); }}>Save</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeafarerProfilePage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState<SeafarerProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<string | null>('overview');
|
||||
|
||||
const [trainingModal, trainingModalHandlers] = useDisclosure(false);
|
||||
const [medicalModal, medicalModalHandlers] = useDisclosure(false);
|
||||
const [seaServiceModal, seaServiceModalHandlers] = useDisclosure(false);
|
||||
const [certModal, certModalHandlers] = useDisclosure(false);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchSeafarerProfile(id)
|
||||
.then(setProfile)
|
||||
.catch((e) => handleError(e))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleStatusChange = async (newStatus: 'Active' | 'Suspended') => {
|
||||
if (!profile) return;
|
||||
try {
|
||||
await updateSeafarerStatus(profile.id, newStatus);
|
||||
setProfile((p) => p ? { ...p, status: newStatus } : p);
|
||||
notify.success(`Status updated to ${newStatus}.`);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const initials = profile ? `${profile.firstName[0]}${profile.lastName[0]}` : '??';
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Breadcrumb */}
|
||||
<Group gap="xs" align="center">
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => navigate('/seafarer-registry')}>
|
||||
<IconArrowLeft size={16} />
|
||||
</ActionIcon>
|
||||
<Text fz="sm" c="dimmed" style={{ cursor: 'pointer' }} onClick={() => navigate('/seafarer-registry')}>
|
||||
Seafarer Registry
|
||||
</Text>
|
||||
<Text fz="sm" c="dimmed">/</Text>
|
||||
<Text fz="sm" fw={500}>
|
||||
{loading ? <Skeleton width={100} height={14} /> : `${profile?.firstName} ${profile?.lastName}`}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Profile header card */}
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
{loading ? (
|
||||
<Group gap="md">
|
||||
<Skeleton circle height={64} />
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<Skeleton height={20} width={200} />
|
||||
<Skeleton height={14} width={300} />
|
||||
<Skeleton height={14} width={400} />
|
||||
</Stack>
|
||||
</Group>
|
||||
) : profile ? (
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="lg" wrap="nowrap" align="flex-start">
|
||||
<Avatar size={64} radius="xl" color="blue" style={{ fontSize: rem(22) }}>
|
||||
{initials}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Title order={3} lh={1.2}>{profile.firstName} {profile.lastName}</Title>
|
||||
<Text fz="sm" c="dimmed" mt={2}>
|
||||
{profile.seafarerId} · Registered {profile.registeredAt}
|
||||
</Text>
|
||||
<Group gap="lg" mt={6} wrap="wrap">
|
||||
<Text fz="sm"><Text span fw={600}>Gender:</Text> {profile.gender}</Text>
|
||||
<Text fz="sm"><Text span fw={600}>DOB:</Text> {profile.dob}</Text>
|
||||
<Text fz="sm"><Text span fw={600}>Nationality:</Text> {profile.nationality}</Text>
|
||||
<Text fz="sm"><Text span fw={600}>Mobile:</Text> {profile.mobile}</Text>
|
||||
<Text fz="sm"><Text span fw={600}>Email:</Text> {profile.email}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
|
||||
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
|
||||
Edit Profile
|
||||
</Button>
|
||||
<Button size="xs" variant="default" leftSection={<IconPrinter size={13} />} onClick={() => notify.info('Print — coming soon.')}>
|
||||
Print Profile
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
) : (
|
||||
<Alert color="red">Profile not found.</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Tabs */}
|
||||
{!loading && profile && (
|
||||
<Tabs value={activeTab} onChange={setActiveTab} variant="outline">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="overview" leftSection={<IconLayoutDashboard size={15} />}>Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="training" leftSection={<IconBook size={15} />}>Training</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={15} />}>Medical</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconShip size={15} />}>Sea Service</Tabs.Tab>
|
||||
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={15} />}>Certifications</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<IconHistory size={15} />}>History</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<OverviewTab profile={profile} onStatusChange={handleStatusChange} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="training">
|
||||
<TrainingTab records={profile.training} onAdd={trainingModalHandlers.open} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="medical">
|
||||
<MedicalTab records={profile.medical} onAdd={medicalModalHandlers.open} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service">
|
||||
<SeaServiceTab records={profile.seaService} onAdd={seaServiceModalHandlers.open} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="certifications">
|
||||
<CertificationsTab records={profile.certifications} onAdd={certModalHandlers.open} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
<HistoryTab entries={profile.history} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<AddTrainingModal opened={trainingModal} onClose={trainingModalHandlers.close} />
|
||||
<AddMedicalModal opened={medicalModal} onClose={medicalModalHandlers.close} />
|
||||
<AddSeaServiceModal opened={seaServiceModal} onClose={seaServiceModalHandlers.close} />
|
||||
<AddCertModal opened={certModal} onClose={certModalHandlers.close} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,559 +1,259 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconAnchor,
|
||||
IconArrowRight,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconClipboardList,
|
||||
IconInfoCircle,
|
||||
IconSchool,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
notify,
|
||||
BilingualInput,
|
||||
useErrorHandler,
|
||||
AmharicDatePicker,
|
||||
toEthiopicDateLabel,
|
||||
toGregorianDateLabel,
|
||||
} from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
STATUS_PROGRESS,
|
||||
TERMINAL_STATUSES,
|
||||
useGetMyApplicationsQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dummy API
|
||||
// ---------------------------------------------------------------------------
|
||||
async function submitSeafarerRegistration(data: unknown): Promise<{ ok: true; referenceId: string }> {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
console.log('Seafarer registration payload:', data);
|
||||
return { ok: true, referenceId: `SEA-${Date.now()}` };
|
||||
}
|
||||
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const NATIONALITIES = [
|
||||
'Ethiopian', 'Eritrean', 'Djiboutian', 'Kenyan', 'Somali', 'Sudanese', 'Other',
|
||||
];
|
||||
const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed'];
|
||||
const GENDERS = ['Male', 'Female'];
|
||||
const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
|
||||
const DEPARTMENT_LABELS: Record<string, string> = {
|
||||
DECK: 'Deck',
|
||||
ENGINE: 'Engine',
|
||||
CATERING: 'Catering',
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Personal Information' },
|
||||
{ label: 'Contact Details' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
const SEAFARER_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
PENDING: 'yellow',
|
||||
SUSPENDED: 'orange',
|
||||
INACTIVE: 'gray',
|
||||
};
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'nationalId', label: 'National ID (Front & Back)', description: 'Both sides of your national identity card', required: true, icon: IconId },
|
||||
{ key: 'passport', label: 'Passport Copy', description: 'Bio-data page of valid passport', required: false, icon: IconFileDescription },
|
||||
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification', required: false, icon: IconSchool },
|
||||
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5cm', required: true, icon: IconCamera },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? (
|
||||
<IconCheck size={18} color="white" stroke={2.5} />
|
||||
) : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={isCurrent ? 700 : 400}
|
||||
c={isCurrent ? 'blue.7' : 'dimmed'}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: rem(2),
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section heading
|
||||
// ---------------------------------------------------------------------------
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review row
|
||||
// ---------------------------------------------------------------------------
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document upload card
|
||||
// ---------------------------------------------------------------------------
|
||||
function DocCard({
|
||||
slot,
|
||||
file,
|
||||
onFile,
|
||||
}: {
|
||||
slot: DocSlot;
|
||||
file: File | null;
|
||||
onFile: (f: File | null) => void;
|
||||
}) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file
|
||||
? 'var(--mantine-color-teal-5)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: rem(44),
|
||||
height: rem(44),
|
||||
borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">
|
||||
{slot.label}
|
||||
{slot.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => { onFile(null); resetRef.current?.(); }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="xs" variant="default" {...props}>
|
||||
Choose File
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* The seafarer's registration home (US-SEA-001…007).
|
||||
*
|
||||
* Registration itself runs through the config-driven licensing wizard — this
|
||||
* page is the state machine around it: start a registration, resume or track
|
||||
* the one in flight, or show the registered identity once approved.
|
||||
*/
|
||||
export function SeafarerRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { handleError } = useErrorHandler();
|
||||
const { profile, isLoading: loadingProfile } = useCurrentProfile();
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery();
|
||||
|
||||
// Step 1 — Personal Information
|
||||
const [firstName, setFirstName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [middleName, setMiddleName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [lastName, setLastName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [gender, setGender] = useState<string | null>(null);
|
||||
const [dob, setDob] = useState('');
|
||||
const [placeOfBirth, setPlaceOfBirth] = useState('');
|
||||
const [nationality, setNationality] = useState<string | null>('Ethiopian');
|
||||
const [maritalStatus, setMaritalStatus] = useState<string | null>(null);
|
||||
const [nationalIdNumber, setNationalIdNumber] = useState('');
|
||||
const [passportNumber, setPassportNumber] = useState('');
|
||||
const [passportExpiry, setPassportExpiry] = useState('');
|
||||
|
||||
// Step 2 — Contact Details
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [locationId, setLocationId] = useState<string | null>(null);
|
||||
const [permanentAddress, setPermanentAddress] = useState('');
|
||||
const [currentAddress, setCurrentAddress] = useState('');
|
||||
const [emergencyName, setEmergencyName] = useState('');
|
||||
const [emergencyRel, setEmergencyRel] = useState<string | null>(null);
|
||||
const [emergencyPhone, setEmergencyPhone] = useState('');
|
||||
|
||||
// Step 3 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
||||
nationalId: null, passport: null, graduation: null, photo: null,
|
||||
});
|
||||
|
||||
const setFile = (key: string) => (f: File | null) =>
|
||||
setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim();
|
||||
if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId;
|
||||
if (active === 2) return !!files.nationalId && !!files.photo;
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await submitSeafarerRegistration({
|
||||
personalInfo: { firstName, middleName, lastName, gender, dob, placeOfBirth, nationality, maritalStatus, nationalIdNumber, passportNumber, passportExpiry },
|
||||
contactDetails: { mobile, email, locationId, permanentAddress, currentAddress, emergency: { name: emergencyName, relationship: emergencyRel, phone: emergencyPhone } },
|
||||
documents: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v?.name ?? null])),
|
||||
});
|
||||
notify.success(`Registration submitted! Reference: ${result.referenceId}`);
|
||||
navigate('/applications');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepLabel = STEPS[active]?.label ?? '';
|
||||
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
|
||||
const StepIcon = stepIcons[active];
|
||||
// The latest registration application, in flight or decided.
|
||||
const registration = useMemo(() => {
|
||||
const mine = (applications?.items ?? []).filter(
|
||||
(app) => app.licenseType?.key === REGISTRATION_TYPE_KEY,
|
||||
);
|
||||
return mine.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0];
|
||||
}, [applications]);
|
||||
|
||||
if (loadingProfile || loadingApplications) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Page header */}
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- registered
|
||||
if (profile?.seafarerNumber) {
|
||||
const status = profile.seafarerStatus ?? 'ACTIVE';
|
||||
return (
|
||||
<Stack maw={720} mx="auto">
|
||||
<Title order={2}>Seafarer Registration</Title>
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<IconCircleCheck size={32} color="var(--mantine-color-green-6)" />
|
||||
<div>
|
||||
<Title order={3}>New Seafarer Registration</Title>
|
||||
<Text fz="sm" c="dimmed">Register a new seafarer profile — Step {active + 1} of {STEPS.length}</Text>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
{/* Card */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
{/* Card header */}
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="xs">
|
||||
<StepIcon size={20} stroke={1.6} />
|
||||
<Text fw={700} fz="lg">{stepLabel}</Text>
|
||||
</Group>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* ── Step 1: Personal Information ───────────────────────────── */}
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Identity Details" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<BilingualInput label="First Name" required value={firstName} onChange={setFirstName} />
|
||||
<BilingualInput label="Middle Name" value={middleName} onChange={setMiddleName} />
|
||||
<BilingualInput label="Last Name" required value={lastName} onChange={setLastName} />
|
||||
<Select label="Gender" placeholder="Select" required data={GENDERS} value={gender} onChange={setGender} />
|
||||
<AmharicDatePicker label="Date of Birth" required value={dob} onChange={setDob} />
|
||||
<TextInput label="Place of Birth" placeholder="City, Region" required value={placeOfBirth} onChange={(e) => setPlaceOfBirth(e.currentTarget.value)} />
|
||||
<Select label="Nationality" required data={NATIONALITIES} value={nationality} onChange={setNationality} searchable />
|
||||
<Select label="Marital Status" placeholder="Select" data={MARITAL_STATUSES} value={maritalStatus} onChange={setMaritalStatus} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Identity Documents" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="National ID Number" placeholder="ET-000000" required value={nationalIdNumber} onChange={(e) => setNationalIdNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Passport Number" placeholder="EP000000" value={passportNumber} onChange={(e) => setPassportNumber(e.currentTarget.value)} />
|
||||
<AmharicDatePicker label="Passport Expiry Date" value={passportExpiry} onChange={setPassportExpiry} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
A unique Seafarer ID will be automatically generated upon approval of this registration.
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Contact Details ─────────────────────────────────── */}
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Contact Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Mobile Number" placeholder="+251 9XX XXX XXX" required value={mobile} onChange={(e) => setMobile(e.currentTarget.value)} />
|
||||
<TextInput label="Email Address" placeholder="email@example.com" type="email" required value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Location" />
|
||||
<LocationPicker
|
||||
value={locationId ?? undefined}
|
||||
onChange={setLocationId}
|
||||
required
|
||||
/>
|
||||
|
||||
<SectionHead title="Address" />
|
||||
<Textarea label="Permanent Address" placeholder="Full permanent address" autosize minRows={2} value={permanentAddress} onChange={(e) => setPermanentAddress(e.currentTarget.value)} />
|
||||
<Textarea
|
||||
label={<><Text span fz="sm" fw={500}>Current Address</Text><Text span fz="xs" c="dimmed" ml={6}>(If different from permanent)</Text></>}
|
||||
placeholder="Full current address"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={currentAddress}
|
||||
onChange={(e) => setCurrentAddress(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<SectionHead title="Emergency Contact" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Contact Name" placeholder="Full name" value={emergencyName} onChange={(e) => setEmergencyName(e.currentTarget.value)} />
|
||||
<Select label="Relationship" placeholder="Select" data={RELATIONSHIPS} value={emergencyRel} onChange={setEmergencyRel} />
|
||||
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" value={emergencyPhone} onChange={(e) => setEmergencyPhone(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Documents Upload ────────────────────────────────── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
|
||||
Please upload clear, readable copies of all required documents. Accepted formats: PDF, JPG, PNG (max 5MB each). Items marked with * are mandatory.
|
||||
</Alert>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<DocCard
|
||||
key={slot.key}
|
||||
slot={slot}
|
||||
file={files[slot.key]}
|
||||
onFile={setFile(slot.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0">
|
||||
<Text fw={600} fz="sm" mb="sm">Upload Progress</Text>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap={6} align="center">
|
||||
{files[slot.key] ? (
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<Box style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
|
||||
)}
|
||||
<Text fz="xs" c={files[slot.key] ? 'teal.7' : 'dimmed'} fw={files[slot.key] ? 600 : 400}>
|
||||
{slot.key === 'nationalId' ? 'National ID' : slot.key === 'passport' ? 'Passport' : slot.key === 'graduation' ? 'Certificate' : 'Photo'}
|
||||
<Text fw={700} size="lg">
|
||||
Registered Seafarer
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Review & Submit ─────────────────────────────────── */}
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Personal Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="First Name" value={`${firstName.en}${firstName.am ? ` / ${firstName.am}` : ''}`} />
|
||||
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
|
||||
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
|
||||
<ReviewRow label="Gender" value={gender ?? ''} />
|
||||
<ReviewRow label="Date of Birth" value={`${dob ? toGregorianDateLabel(dob) : ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
|
||||
<ReviewRow label="Place of Birth" value={placeOfBirth} />
|
||||
<ReviewRow label="Nationality" value={nationality ?? ''} />
|
||||
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
|
||||
<ReviewRow label="National ID No." value={nationalIdNumber} />
|
||||
<ReviewRow label="Passport No." value={passportNumber} />
|
||||
<ReviewRow label="Passport Expiry" value={passportExpiry ? toGregorianDateLabel(passportExpiry) : ''} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Contact Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Mobile" value={mobile} />
|
||||
<ReviewRow label="Email" value={email} />
|
||||
<ReviewRow label="Location" value={locationId ?? ''} />
|
||||
<ReviewRow label="Permanent Address" value={permanentAddress} />
|
||||
<ReviewRow label="Current Address" value={currentAddress} />
|
||||
</SimpleGrid>
|
||||
{emergencyName && (
|
||||
<>
|
||||
<Divider mt="md" mb="sm" />
|
||||
<Text fw={600} fz="sm" mb="sm">Emergency Contact</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Name" value={emergencyName} />
|
||||
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
|
||||
<ReviewRow label="Phone" value={emergencyPhone} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap="xs" align="center">
|
||||
{files[slot.key] ? (
|
||||
<IconCircleCheck size={18} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<Box style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)', flexShrink: 0 }} />
|
||||
)}
|
||||
<div>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label}
|
||||
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
|
||||
<Text c="dimmed" size="sm">
|
||||
Your official seafarer profile with the Ethiopian Maritime
|
||||
Authority.
|
||||
</Text>
|
||||
{files[slot.key] && (
|
||||
<Text fz="xs" c="dimmed" truncate maw={160}>{files[slot.key]!.name}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
<Badge color={SEAFARER_STATUS_COLORS[status] ?? 'gray'} size="lg">
|
||||
{status}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="xl" mt="sm">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Seafarer Number
|
||||
</Text>
|
||||
<Text fw={700} ff="monospace" size="lg">
|
||||
{profile.seafarerNumber}
|
||||
</Text>
|
||||
</div>
|
||||
{profile.seafarerDepartment && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Department
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
{status === 'SUSPENDED' && profile.seafarerStatusReason && (
|
||||
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
|
||||
Your profile is suspended: {profile.seafarerStatusReason}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => navigate('/applications')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{active < STEPS.length - 1 ? (
|
||||
</Card>
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>Sea service & medical records</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Keep your sea-service history and medical certificates up to
|
||||
date — certificate and seaman-book applications draw on them.
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={next}
|
||||
disabled={!canNext()}
|
||||
onClick={() => navigate('/seafarer/records')}
|
||||
>
|
||||
Next Step
|
||||
My records
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="blue"
|
||||
leftSection={<IconCircleCheck size={16} />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
>
|
||||
Submit Registration
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- in flight
|
||||
if (registration && !TERMINAL_STATUSES.includes(registration.status)) {
|
||||
const isDraft = registration.status === 'DRAFT';
|
||||
const needsAction = registration.status === 'RESUBMIT_REQUIRED';
|
||||
return (
|
||||
<Stack maw={720} mx="auto">
|
||||
<Title order={2}>Seafarer Registration</Title>
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={700}>{registration.applicationNumber}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Submitted registrations are reviewed by an EMA registration
|
||||
officer; you will be notified of every decision.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge color={STATUS_COLORS[registration.status]} size="lg">
|
||||
{STATUS_LABELS[registration.status]}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Progress value={STATUS_PROGRESS[registration.status]} />
|
||||
{needsAction && (
|
||||
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
|
||||
The registration officer asked for corrections. Open the
|
||||
application to see exactly what needs fixing.
|
||||
</Alert>
|
||||
)}
|
||||
<Group>
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
isDraft || needsAction
|
||||
? `/licensing/${REGISTRATION_TYPE_KEY}/apply`
|
||||
: `/licensing/${REGISTRATION_TYPE_KEY}/applications/${registration.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDraft
|
||||
? 'Continue registration'
|
||||
: needsAction
|
||||
? 'Fix and resubmit'
|
||||
: 'View application'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- not yet started
|
||||
return (
|
||||
<Stack maw={720} mx="auto">
|
||||
<Title order={2}>Seafarer Registration</Title>
|
||||
{registration?.status === 'REJECTED' && (
|
||||
<Alert color="red" title="Previous registration rejected">
|
||||
{registration.rejectionReason ??
|
||||
'Your previous registration was rejected. You may register again.'}
|
||||
</Alert>
|
||||
)}
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Stack>
|
||||
<Group>
|
||||
<IconAnchor size={32} color="var(--mantine-color-blue-6)" />
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
Register as a seafarer
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Approval creates your official seafarer profile with a unique
|
||||
seafarer number — the identity every maritime service builds on.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fw={600} size="sm" mt="sm">
|
||||
You will need:
|
||||
</Text>
|
||||
<List
|
||||
size="sm"
|
||||
spacing={4}
|
||||
icon={<IconClipboardList size={16} color="var(--mantine-color-blue-5)" />}
|
||||
>
|
||||
<List.Item>A passport-size photograph</List.Item>
|
||||
<List.Item>Your National ID (Fayda) or Kebele ID</List.Item>
|
||||
<List.Item>Your educational certificate</List.Item>
|
||||
<List.Item>
|
||||
A medical fitness certificate and passport, if you already hold
|
||||
them
|
||||
</List.Item>
|
||||
</List>
|
||||
<Group mt="md">
|
||||
<Button
|
||||
size="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
|
||||
>
|
||||
Start registration
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileExport,
|
||||
IconSearch,
|
||||
IconUserCheck,
|
||||
IconUsers,
|
||||
IconUserX,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface Seafarer {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
gender: 'Male' | 'Female';
|
||||
nationality: string;
|
||||
mobile: string;
|
||||
region: string;
|
||||
registeredAt: string;
|
||||
medicalStatus: 'Fit' | 'Unfit' | 'Pending';
|
||||
bookStatus: 'Active' | 'Expired' | 'Suspended' | 'Pending';
|
||||
status: 'Active' | 'Pending' | 'Suspended';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dummy API — replace with real fetch later
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchSeafarers(): Promise<Seafarer[]> {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
seafarerId: 'SF-2024-0001',
|
||||
firstName: 'Abebe',
|
||||
lastName: 'Girma',
|
||||
email: 'abebe.g@email.com',
|
||||
gender: 'Male',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 911 234 567',
|
||||
region: 'Addis Ababa',
|
||||
registeredAt: '2024-01-10',
|
||||
medicalStatus: 'Fit',
|
||||
bookStatus: 'Active',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
seafarerId: 'SF-2024-0002',
|
||||
firstName: 'Sara',
|
||||
lastName: 'Tadesse',
|
||||
email: 'sara.t@email.com',
|
||||
gender: 'Female',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 922 345 678',
|
||||
region: 'Dire Dawa',
|
||||
registeredAt: '2024-02-14',
|
||||
medicalStatus: 'Pending',
|
||||
bookStatus: 'Pending',
|
||||
status: 'Pending',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
seafarerId: 'SF-2024-0003',
|
||||
firstName: 'Dawit',
|
||||
lastName: 'Bekele',
|
||||
email: 'dawit.b@email.com',
|
||||
gender: 'Male',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 933 456 789',
|
||||
region: 'Oromia',
|
||||
registeredAt: '2024-03-05',
|
||||
medicalStatus: 'Fit',
|
||||
bookStatus: 'Expired',
|
||||
status: 'Suspended',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
seafarerId: 'SF-2024-0004',
|
||||
firstName: 'Hana',
|
||||
lastName: 'Mulugeta',
|
||||
email: 'hana.m@email.com',
|
||||
gender: 'Female',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 944 567 890',
|
||||
region: 'Amhara',
|
||||
registeredAt: '2024-04-20',
|
||||
medicalStatus: 'Fit',
|
||||
bookStatus: 'Active',
|
||||
status: 'Active',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat card
|
||||
// ---------------------------------------------------------------------------
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
loading,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof IconUsers;
|
||||
color: string;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
{loading ? (
|
||||
<Skeleton height={28} width={40} mb={6} />
|
||||
) : (
|
||||
<Title order={2} lh={1}>{value}</Title>
|
||||
)}
|
||||
<Text fz="sm" c="dimmed" mt={4}>{label}</Text>
|
||||
</div>
|
||||
<ThemeIcon variant="light" color={color} size={46} radius="md">
|
||||
<Icon size={22} stroke={1.6} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badges
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Active: 'teal',
|
||||
Pending: 'yellow',
|
||||
Suspended: 'red',
|
||||
Expired: 'orange',
|
||||
Fit: 'teal',
|
||||
Unfit: 'red',
|
||||
};
|
||||
|
||||
function StatusBadge({ value }: { value: string }) {
|
||||
return (
|
||||
<Badge
|
||||
color={STATUS_COLOR[value] ?? 'gray'}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeafarerRegistryPage() {
|
||||
const navigate = useNavigate();
|
||||
const [seafarers, setSeafarers] = useState<Seafarer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSeafarers()
|
||||
.then(setSeafarers)
|
||||
.catch((e) => handleError(e))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const stats = {
|
||||
total: seafarers.length,
|
||||
active: seafarers.filter((s) => s.status === 'Active').length,
|
||||
pending: seafarers.filter((s) => s.status === 'Pending').length,
|
||||
suspended: seafarers.filter((s) => s.status === 'Suspended').length,
|
||||
};
|
||||
|
||||
const filtered = seafarers.filter((s) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch =
|
||||
!q ||
|
||||
s.seafarerId.toLowerCase().includes(q) ||
|
||||
`${s.firstName} ${s.lastName}`.toLowerCase().includes(q) ||
|
||||
s.mobile.includes(q) ||
|
||||
s.email.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || s.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const rows = filtered.map((s) => (
|
||||
<Table.Tr key={s.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={600} c="blue.7" style={{ cursor: 'pointer' }} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
|
||||
{s.seafarerId}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>{s.firstName} {s.lastName}</Text>
|
||||
<Text fz="xs" c="dimmed">{s.email}</Text>
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.gender}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.nationality}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.status} /></Table.Td>
|
||||
<Table.Td>
|
||||
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconDotsVertical size={15} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
|
||||
View
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconX size={14} />} color="red" onClick={() => notify.info('Suspend — coming soon.')}>
|
||||
Suspend
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registry</Title>
|
||||
<Text fz="sm" c="dimmed">Manage all registered seafarers</Text>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconAnchor size={16} />}
|
||||
onClick={() => navigate('/seafarer-registration')}
|
||||
>
|
||||
+ New Registration
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatCard label="Total Seafarers" value={stats.total} icon={IconUsers} color="blue" loading={loading} />
|
||||
<StatCard label="Active" value={stats.active} icon={IconUserCheck} color="teal" loading={loading} />
|
||||
<StatCard label="Pending" value={stats.pending} icon={IconClock} color="yellow" loading={loading} />
|
||||
<StatCard label="Suspended" value={stats.suspended} icon={IconUserX} color="red" loading={loading} />
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table card */}
|
||||
<Paper withBorder radius="md">
|
||||
{/* Toolbar */}
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Seafarer List</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, ID or mobile…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ minWidth: rem(260) }}
|
||||
size="sm"
|
||||
rightSection={
|
||||
search ? (
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}>
|
||||
<IconX size={13} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Status"
|
||||
data={['Active', 'Pending', 'Suspended']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ width: rem(140) }}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={34}
|
||||
title="Export"
|
||||
onClick={() => notify.info('Export — coming soon.')}
|
||||
>
|
||||
<IconFileExport size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<Stack gap="xs" p="md">
|
||||
{[...Array(4)].map((_, i) => <Skeleton key={i} height={44} radius="sm" />)}
|
||||
</Stack>
|
||||
) : filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconUsers size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No seafarers found</Text>
|
||||
{(search || statusFilter) && (
|
||||
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setStatusFilter(null); }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped withColumnBorders={false} verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Seafarer ID', 'Name', 'Gender', 'Nationality', 'Mobile', 'Region', 'Reg. Date', 'Medical', 'Book Status', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ whiteSpace: 'nowrap', fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>
|
||||
{h}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {seafarers.length} seafarers</Text>
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="dimmed">Data loaded</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useVerifyCertificateQuery } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Public certificate verification (US-PORTAL-008) — the page every printed
|
||||
* QR code points at. Deliberately public and chrome-free: a bank clerk or
|
||||
* port official scanning a certificate has no portal account.
|
||||
*/
|
||||
export function VerifyCertificatePage() {
|
||||
const { code } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [manualCode, setManualCode] = useState('');
|
||||
const { data, isLoading, isError } = useVerifyCertificateQuery(code ?? '', {
|
||||
skip: !code,
|
||||
});
|
||||
|
||||
const rows: [string, string | null | undefined][] = data?.valid
|
||||
? [
|
||||
['Certificate number', data.certificateNumber],
|
||||
['Licence / registration', data.licenseType],
|
||||
['Holder', data.companyName],
|
||||
['Issued', data.issueDate],
|
||||
['Expires', data.expiryDate],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Container size="xs" py="xl">
|
||||
<Stack align="center" gap="lg">
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={28} color="var(--mantine-color-blue-6)" />
|
||||
<Title order={3}>EMA Certificate Verification</Title>
|
||||
</Group>
|
||||
|
||||
{!code && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Scan the QR code on a certificate, or enter its verification
|
||||
code below.
|
||||
</Text>
|
||||
<Group>
|
||||
<TextInput
|
||||
flex={1}
|
||||
placeholder="Verification code"
|
||||
value={manualCode}
|
||||
onChange={(e) => setManualCode(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconSearch size={16} />}
|
||||
disabled={!manualCode.trim()}
|
||||
onClick={() => navigate(`/verify/${manualCode.trim()}`)}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{code && isLoading && (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{code && !isLoading && (isError || !data) && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Text c="red" ta="center">
|
||||
Verification is temporarily unavailable. Please try again.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{code && data && (
|
||||
<Card withBorder radius="md" p="lg" w="100%">
|
||||
<Stack>
|
||||
{data.valid ? (
|
||||
<Group>
|
||||
<IconCircleCheck
|
||||
size={40}
|
||||
color="var(--mantine-color-green-6)"
|
||||
/>
|
||||
<div>
|
||||
<Text fw={700} size="lg" c="green">
|
||||
Valid certificate
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Issued by the Ethiopian Maritime Authority.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<IconCircleX size={40} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text fw={700} size="lg" c="red">
|
||||
Not valid
|
||||
</Text>
|
||||
{data.status && <Badge color="red">{data.status}</Badge>}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.reason === 'not_found'
|
||||
? 'No certificate matches this code.'
|
||||
: 'This certificate is no longer valid.'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Table variant="vertical" layout="fixed">
|
||||
<Table.Tbody>
|
||||
{rows
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => (
|
||||
<Table.Tr key={label}>
|
||||
<Table.Th w={170}>{label}</Table.Th>
|
||||
<Table.Td>{value}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={() => {
|
||||
setManualCode('');
|
||||
navigate('/verify');
|
||||
}}
|
||||
>
|
||||
Verify another certificate
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselOwnerDashboardPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel owner dashboard"
|
||||
description="Vessel registration is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselOwnerDashboardPage;
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconAlertCircle,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
export function VesselOwnerLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loginTrigger] = useApiMutation<{ token: string; user: { id: string; name: string } }>();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError('Please enter your email and password.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginTrigger({
|
||||
url: '/auth/vessel-owner/login',
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
}).unwrap();
|
||||
notify.success('Login successful. Welcome!');
|
||||
navigate('/vessel-owner/dashboard');
|
||||
} catch {
|
||||
setError('Invalid email or password. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Stack align="center" gap="xl" w="100%" maw={440} px="md">
|
||||
{/* Brand */}
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
|
||||
<IconShip size={36} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} ta="center">Vessel Owner Portal</Title>
|
||||
<Text fz="sm" c="dimmed" ta="center">
|
||||
Ethiopian Maritime Affairs Authority
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
|
||||
<Group gap="xs" mb="lg">
|
||||
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
|
||||
<Text fw={700} fz="lg">Sign In</Text>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="owner@example.com"
|
||||
leftSection={<IconMail size={16} />}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
leftSection={<IconLock size={16} />}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
/>
|
||||
<Anchor fz="sm" ta="right" onClick={() => navigate('/vessel-owner/forgot-password')}>
|
||||
Forgot password?
|
||||
</Anchor>
|
||||
<Button fullWidth size="md" loading={loading} onClick={handleLogin} leftSection={<IconAnchor size={16} />}>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Divider my="md" label="Don't have an account?" labelPosition="center" />
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
onClick={() => navigate('/vessel-owner/register')}
|
||||
>
|
||||
Create Vessel Owner Account
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Text fz="xs" c="dimmed" ta="center">
|
||||
This portal is exclusively for vessel owners. For seafarer services,{' '}
|
||||
<Anchor fz="xs" onClick={() => navigate('/login')}>sign in here</Anchor>.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconPhone,
|
||||
IconShip,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify, passwordMeetsAll, PasswordRequirements } from '@ema-platform/ui';
|
||||
|
||||
const OWNER_TYPES = [
|
||||
'Individual (Private Owner)',
|
||||
'Private Company / PLC',
|
||||
'State Enterprise',
|
||||
'NGO / Non-Profit',
|
||||
'Government Agency',
|
||||
];
|
||||
|
||||
export function VesselOwnerRegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [ownerType, setOwnerType] = useState<string | null>(null);
|
||||
const [nationalIdOrTin, setNationalIdOrTin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [registerTrigger] = useApiMutation<{ id: string }>();
|
||||
|
||||
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && passwordMeetsAll(password, 8) && password === confirmPassword;
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!canSubmit) {
|
||||
setError('Please fill in all required fields. Password must meet all requirements and match.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await registerTrigger({
|
||||
url: '/auth/vessel-owner/register',
|
||||
method: 'POST',
|
||||
body: { fullName, email, phone, ownerType, nationalIdOrTin, password },
|
||||
}).unwrap();
|
||||
setSuccess(true);
|
||||
notify.success('Account created! You can now sign in.');
|
||||
} catch {
|
||||
setError('Registration failed. This email may already be registered.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Paper withBorder radius="lg" p="xl" w="100%" maw={440} shadow="sm" mx="md">
|
||||
<Stack align="center" gap="md">
|
||||
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
|
||||
<IconCheck size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} ta="center">Account Created!</Title>
|
||||
<Text fz="sm" c="dimmed" ta="center">
|
||||
Your vessel owner account has been created. You can now sign in and submit vessel registration applications.
|
||||
</Text>
|
||||
<Button fullWidth onClick={() => navigate('/vessel-owner/login')}>
|
||||
Sign In Now
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Stack align="center" gap="xl" w="100%" maw={540} px="md">
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
|
||||
<IconShip size={36} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} ta="center">Create Vessel Owner Account</Title>
|
||||
<Text fz="sm" c="dimmed" ta="center">Ethiopian Maritime Affairs Authority</Text>
|
||||
</Stack>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
|
||||
<Group gap="xs" mb="lg">
|
||||
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
|
||||
<Text fw={700} fz="lg">Owner Registration</Text>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">{error}</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Full Name / Company Name"
|
||||
placeholder="e.g. Abebe Girma"
|
||||
leftSection={<IconUser size={16} />}
|
||||
required
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Owner Type"
|
||||
placeholder="Select type"
|
||||
required
|
||||
data={OWNER_TYPES}
|
||||
value={ownerType}
|
||||
onChange={setOwnerType}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="owner@example.com"
|
||||
leftSection={<IconMail size={16} />}
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone Number"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
leftSection={<IconPhone size={16} />}
|
||||
required
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="National ID / TIN"
|
||||
placeholder="ET-0000000 or TIN"
|
||||
required
|
||||
value={nationalIdOrTin}
|
||||
onChange={(e) => setNationalIdOrTin(e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Set Password" labelPosition="center" />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Min. 8 characters"
|
||||
leftSection={<IconLock size={16} />}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<PasswordRequirements password={password} minLength={8} />
|
||||
</div>
|
||||
<PasswordInput
|
||||
label="Confirm Password"
|
||||
placeholder="Repeat password"
|
||||
leftSection={<IconLock size={16} />}
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
error={confirmPassword && password !== confirmPassword ? 'Passwords do not match' : undefined}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Button fullWidth size="md" loading={loading} disabled={!canSubmit} onClick={handleRegister}>
|
||||
Create Account
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Divider my="md" label="Already have an account?" labelPosition="center" />
|
||||
<Button fullWidth variant="light" onClick={() => navigate('/vessel-owner/login')}>
|
||||
Sign In
|
||||
</Button>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
IconTransferIn,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
|
||||
type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
|
||||
|
||||
interface VesselRegistrationSummary {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
category: string;
|
||||
status: VesselRegStatus;
|
||||
submittedDate: string;
|
||||
renewalStatus: 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
|
||||
expiryDate: string | null;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Correction Required': 'orange',
|
||||
};
|
||||
|
||||
const RENEWAL_COLOR: Record<string, string> = {
|
||||
Valid: 'teal',
|
||||
'Due Soon': 'orange',
|
||||
Overdue: 'red',
|
||||
'Not Applicable': 'gray',
|
||||
};
|
||||
|
||||
export function VesselRegistrationDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [vessels, setVessels] = useState<VesselRegistrationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistrationSummary[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setVessels(data))
|
||||
.catch(() => setVessels([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const stats = {
|
||||
total: vessels.length,
|
||||
pending: vessels.filter((v) => v.status === 'Pending' || v.status === 'Under Review' || v.status === 'Correction Required').length,
|
||||
approved: vessels.filter((v) => v.status === 'Approved').length,
|
||||
renewalsDue: vessels.filter((v) => v.renewalStatus === 'Due Soon' || v.renewalStatus === 'Overdue').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconShip size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>My Vessel Registration Dashboard</Title>
|
||||
<Text fz="sm" c="dimmed">Track your vessel registrations and ownership transfers</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" leftSection={<IconTransferIn size={16} />} onClick={() => navigate('/vessel-registration/transfer')}>
|
||||
Ownership Transfer
|
||||
</Button>
|
||||
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration/apply')}>
|
||||
Register New Vessel
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Total Vessels</Text>
|
||||
<Text fz="xl" fw={700} c="blue.6" mt={4}>{stats.total}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>In Progress</Text>
|
||||
<Text fz="xl" fw={700} c="yellow.7" mt={4}>{stats.pending}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Approved</Text>
|
||||
<Text fz="xl" fw={700} c="teal.6" mt={4}>{stats.approved}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Renewals Due</Text>
|
||||
<Text fz="xl" fw={700} c="orange.6" mt={4}>{stats.renewalsDue}</Text>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{!loading && vessels.length === 0 ? (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<ThemeIcon size={56} radius="xl" color="blue" variant="light">
|
||||
<IconAnchor size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} ta="center">No Vessels Registered</Title>
|
||||
<Text fz="sm" c="dimmed" ta="center" maw={400}>
|
||||
You haven't registered any vessels yet. Click "Register New Vessel" to begin.
|
||||
</Text>
|
||||
<Button onClick={() => navigate('/vessel-registration/apply')}>Start Registration</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{vessels.map((v) => (
|
||||
<Card key={v.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light">
|
||||
<IconShip size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{v.vesselName}</Text>
|
||||
<Text fz="xs" c="dimmed">{v.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[v.status] ?? 'gray'}>{v.status}</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Text fz="xs" c="dimmed">Renewal</Text>
|
||||
<Badge size="xs" variant="light" color={RENEWAL_COLOR[v.renewalStatus]}>{v.renewalStatus}</Badge>
|
||||
</Group>
|
||||
<Button size="xs" variant="light" fullWidth mt="sm" onClick={() => navigate('/vessel-registration')}>
|
||||
View Details
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
Vessel registration is valid for <strong>5 years</strong> from the approval date. You will be notified when renewal is due.
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,678 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
IconWaveSine,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const STEPS = [
|
||||
{ label: 'Vessel Category' },
|
||||
{ label: 'Vessel Details' },
|
||||
{ label: 'Technical & Ownership' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
const VESSEL_TYPES_INLAND = [
|
||||
'Passenger Ferry', 'Cargo Barge', 'Fishing Vessel', 'Tug Boat',
|
||||
'Dredger', 'Patrol/Inspection Boat', 'Pleasure Craft', 'Water Taxi',
|
||||
];
|
||||
|
||||
const VESSEL_TYPES_SEAGOING = [
|
||||
'Container Ship', 'Bulk Carrier', 'Tanker', 'General Cargo',
|
||||
'Ro-Ro Vessel', 'Passenger/Cruise Ship', 'Fishing Vessel', 'Trawler',
|
||||
'Yacht/Pleasure Craft', 'Chemical Tanker', 'LPG Carrier', 'Multi-Purpose Vessel',
|
||||
];
|
||||
|
||||
const ENGINE_TYPES = [
|
||||
'Diesel Engine', 'Dual-Fuel Engine', 'Electric Motor', 'Hybrid Diesel-Electric',
|
||||
'Steam Turbine', 'Gas Turbine', 'Outboard Motor', 'Inboard Petrol Engine',
|
||||
];
|
||||
|
||||
const HULL_MATERIALS = [
|
||||
'Steel', 'Aluminum', 'Fiberglass/GRP', 'Wood', 'Ferro-Cement',
|
||||
];
|
||||
|
||||
const PASSENGER_VESSEL_TYPES = new Set([
|
||||
'Passenger Ferry', 'Passenger/Cruise Ship', 'Water Taxi', 'Yacht/Pleasure Craft', 'Pleasure Craft',
|
||||
]);
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step indicator (matches SeafarerRegistrationPage pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? (
|
||||
<IconCheck size={18} color="white" stroke={2.5} />
|
||||
) : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={isCurrent ? 700 : 400}
|
||||
c={isCurrent ? 'blue.7' : 'dimmed'}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: rem(2),
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({
|
||||
slot,
|
||||
file,
|
||||
onFile,
|
||||
}: {
|
||||
slot: DocSlot;
|
||||
file: File | null;
|
||||
onFile: (f: File | null) => void;
|
||||
}) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file
|
||||
? 'var(--mantine-color-teal-5)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: rem(44),
|
||||
height: rem(44),
|
||||
borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">
|
||||
{slot.label}
|
||||
{slot.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => { onFile(null); resetRef.current?.(); }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="xs" variant="default" {...props}>
|
||||
Choose File
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function VesselRegistrationApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Step 0 — Category
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
|
||||
// Step 1 — Vessel Details
|
||||
const [vesselName, setVesselName] = useState('');
|
||||
const [vesselType, setVesselType] = useState<string | null>(null);
|
||||
const [capacityValue, setCapacityValue] = useState<string | number>('');
|
||||
const [vesselLengthM, setVesselLengthM] = useState<string | number>('');
|
||||
const [flagState, setFlagState] = useState('Ethiopia');
|
||||
const [portOfRegistry, setPortOfRegistry] = useState('');
|
||||
|
||||
// Step 2 — Technical & Ownership
|
||||
const [imoOrHullNumber, setImoOrHullNumber] = useState('');
|
||||
const [manufacturerShipyard, setManufacturerShipyard] = useState('');
|
||||
const [yearBuilt, setYearBuilt] = useState<string | number>('');
|
||||
const [engineType, setEngineType] = useState<string | null>(null);
|
||||
const [enginePowerKw, setEnginePowerKw] = useState<string | number>('');
|
||||
const [numberOfEngines, setNumberOfEngines] = useState<string | number>('');
|
||||
const [hullMaterial, setHullMaterial] = useState<string | null>(null);
|
||||
const [ownerName, setOwnerName] = useState('');
|
||||
const [ownerNationalIdOrTin, setOwnerNationalIdOrTin] = useState('');
|
||||
const [ownerPhone, setOwnerPhone] = useState('');
|
||||
const [ownerAddress, setOwnerAddress] = useState('');
|
||||
|
||||
// Step 3 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
||||
vesselPhotos: null, proofOfOwnership: null, shipParticulars: null, insuranceCertificate: null,
|
||||
});
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
// Reset vessel type when category changes
|
||||
useEffect(() => { setVesselType(null); }, [category]);
|
||||
|
||||
// Derived
|
||||
const vesselTypeOptions = category === 'Inland Waterway Vessel' ? VESSEL_TYPES_INLAND : VESSEL_TYPES_SEAGOING;
|
||||
const capacityLabel = PASSENGER_VESSEL_TYPES.has(vesselType ?? '') ? 'Passenger Capacity' : 'Gross Tonnage (GT)';
|
||||
const idLabel = category === 'Sea-going Vessel (International)' ? 'IMO Number' : 'Hull/Registration Number';
|
||||
|
||||
// Inland: only vessel photos required
|
||||
// Sea-going: vessel photos + proof of ownership + ship particulars + insurance
|
||||
const docSlots: DocSlot[] = category === 'Inland Waterway Vessel'
|
||||
? [
|
||||
{ key: 'vesselPhotos', label: 'Vessel Photos (min. 2)', description: 'Clear exterior and interior photos of the vessel', required: true, icon: IconCamera },
|
||||
]
|
||||
: [
|
||||
{ key: 'vesselPhotos', label: 'Vessel Photos (min. 2)', description: 'Clear exterior and interior photos of the vessel', required: true, icon: IconCamera },
|
||||
{ key: 'proofOfOwnership', label: 'Proof of Ownership / Bill of Sale', description: 'Legal document proving ownership of the vessel', required: true, icon: IconFileDescription },
|
||||
{ key: 'shipParticulars', label: 'Ship Particulars', description: 'Detailed technical specifications issued by the shipyard', required: true, icon: IconId },
|
||||
{ key: 'insuranceCertificate', label: 'Insurance Certificate', description: 'Valid hull and machinery insurance policy', required: true, icon: IconShieldCheck },
|
||||
];
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return !!category;
|
||||
if (active === 1) return (
|
||||
!!vesselName.trim() && !!vesselType && !!capacityValue && !!vesselLengthM &&
|
||||
!!flagState.trim() && !!portOfRegistry.trim()
|
||||
);
|
||||
if (active === 2) return (
|
||||
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
|
||||
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
|
||||
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
|
||||
);
|
||||
if (active === 3) return category === 'Inland Waterway Vessel'
|
||||
? !!files.vesselPhotos
|
||||
: !!files.vesselPhotos && !!files.proofOfOwnership && !!files.shipParticulars && !!files.insuranceCertificate;
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({
|
||||
url: '/vessel-registrations',
|
||||
method: 'POST',
|
||||
body: {
|
||||
category, vesselName, vesselType, capacityLabel, capacityValue, vesselLengthM,
|
||||
flagState, portOfRegistry, imoOrHullNumber, manufacturerShipyard, yearBuilt,
|
||||
engineType, enginePowerKw, numberOfEngines, hullMaterial,
|
||||
ownerName, ownerNationalIdOrTin, ownerPhone, ownerAddress,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('Vessel registration submitted successfully!');
|
||||
navigate('/vessel-registration');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registration')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Title order={3}>Vessel Registration Application</Title>
|
||||
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* ── Step 0: Vessel Category ──────────────────────────────── */}
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">Select the primary use category of the vessel to be registered.</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{[
|
||||
{
|
||||
value: 'Inland Waterway Vessel',
|
||||
icon: IconWaveSine,
|
||||
title: 'Inland Waterway Vessel',
|
||||
desc: 'Vessels operating on lakes, rivers, and inland waterways within Ethiopia (e.g. Lake Tana, Hawassa, Blue Nile)',
|
||||
},
|
||||
{
|
||||
value: 'Sea-going Vessel (International)',
|
||||
icon: IconShip,
|
||||
title: 'Sea-going Vessel (International)',
|
||||
desc: 'Vessels operating in international waters, Red Sea, Gulf of Aden, and ocean routes',
|
||||
},
|
||||
].map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const selected = category === opt.value;
|
||||
return (
|
||||
<Card
|
||||
key={opt.value}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="lg"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: selected ? 'var(--mantine-color-blue-5)' : 'var(--mantine-color-default-border)',
|
||||
borderWidth: selected ? 2 : 1,
|
||||
background: selected ? 'var(--mantine-color-blue-light)' : undefined,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onClick={() => { setCategory(opt.value); next(); }}
|
||||
>
|
||||
<ThemeIcon size={48} radius="md" color="blue" variant={selected ? 'filled' : 'light'} mb="sm">
|
||||
<Icon size={26} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} fz="md" mb={4}>{opt.title}</Text>
|
||||
<Text fz="sm" c="dimmed">{opt.desc}</Text>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Vessel Details ───────────────────────────────── */}
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Category: <strong>{category}</strong>
|
||||
</Alert>
|
||||
<SectionHead title="Vessel Identification" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Vessel Name"
|
||||
placeholder="e.g. Lake Tana Star"
|
||||
required
|
||||
value={vesselName}
|
||||
onChange={(e) => setVesselName(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Vessel Type"
|
||||
placeholder="Select vessel type"
|
||||
required
|
||||
data={vesselTypeOptions}
|
||||
value={vesselType}
|
||||
onChange={setVesselType}
|
||||
/>
|
||||
<NumberInput
|
||||
label={capacityLabel}
|
||||
placeholder="Enter value"
|
||||
required
|
||||
min={1}
|
||||
value={capacityValue}
|
||||
onChange={setCapacityValue}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Vessel Length (meters)"
|
||||
placeholder="e.g. 32"
|
||||
required
|
||||
min={1}
|
||||
value={vesselLengthM}
|
||||
onChange={setVesselLengthM}
|
||||
/>
|
||||
<TextInput
|
||||
label="Flag State"
|
||||
required
|
||||
value={flagState}
|
||||
onChange={(e) => setFlagState(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Registration Area"
|
||||
placeholder="e.g. Bahir Dar"
|
||||
required
|
||||
value={portOfRegistry}
|
||||
onChange={(e) => setPortOfRegistry(e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Technical & Ownership ───────────────────────── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Technical Specifications" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label={idLabel}
|
||||
placeholder={category === 'Sea-going Vessel (International)' ? 'IMO0000000' : 'ETH-INL-0000'}
|
||||
required
|
||||
value={imoOrHullNumber}
|
||||
onChange={(e) => setImoOrHullNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Manufacturer / Shipyard Name"
|
||||
placeholder="e.g. Hyundai Heavy Industries"
|
||||
required
|
||||
value={manufacturerShipyard}
|
||||
onChange={(e) => setManufacturerShipyard(e.currentTarget.value)}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Year Built"
|
||||
placeholder="e.g. 2019"
|
||||
required
|
||||
min={1900}
|
||||
max={new Date().getFullYear()}
|
||||
value={yearBuilt}
|
||||
onChange={setYearBuilt}
|
||||
/>
|
||||
<Select
|
||||
label="Engine Type"
|
||||
placeholder="Select engine type"
|
||||
required
|
||||
data={ENGINE_TYPES}
|
||||
value={engineType}
|
||||
onChange={setEngineType}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Engine Power (kW)"
|
||||
placeholder="e.g. 450"
|
||||
required
|
||||
min={1}
|
||||
value={enginePowerKw}
|
||||
onChange={setEnginePowerKw}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Number of Engines"
|
||||
placeholder="e.g. 2"
|
||||
required
|
||||
min={1}
|
||||
max={12}
|
||||
value={numberOfEngines}
|
||||
onChange={setNumberOfEngines}
|
||||
/>
|
||||
<Select
|
||||
label="Hull Material"
|
||||
placeholder="Select material"
|
||||
required
|
||||
data={HULL_MATERIALS}
|
||||
value={hullMaterial}
|
||||
onChange={setHullMaterial}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Owner Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Owner Name / Company"
|
||||
placeholder="e.g. Abebe Girma"
|
||||
required
|
||||
value={ownerName}
|
||||
onChange={(e) => setOwnerName(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="National ID / TIN"
|
||||
placeholder="e.g. ET-9812345"
|
||||
required
|
||||
value={ownerNationalIdOrTin}
|
||||
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
required
|
||||
value={ownerPhone}
|
||||
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner Address"
|
||||
placeholder="City, Region"
|
||||
value={ownerAddress}
|
||||
onChange={(e) => setOwnerAddress(e.currentTarget.value)}
|
||||
style={{ gridColumn: 'span 2' }}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Documents Upload ─────────────────────────────── */}
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{docSlots.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Review & Submit ──────────────────────────────── */}
|
||||
{active === 4 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Please review all information before submitting. You will be notified by the authority on application status.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Vessel Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Category" value={category ?? ''} />
|
||||
<ReviewRow label="Vessel Name" value={vesselName} />
|
||||
<ReviewRow label="Vessel Type" value={vesselType ?? ''} />
|
||||
<ReviewRow label={capacityLabel} value={String(capacityValue)} />
|
||||
<ReviewRow label="Vessel Length (m)" value={String(vesselLengthM)} />
|
||||
<ReviewRow label="Flag State" value={flagState} />
|
||||
<ReviewRow label="Registration Area" value={portOfRegistry} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Technical Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label={idLabel} value={imoOrHullNumber} />
|
||||
<ReviewRow label="Manufacturer / Shipyard" value={manufacturerShipyard} />
|
||||
<ReviewRow label="Year Built" value={String(yearBuilt)} />
|
||||
<ReviewRow label="Engine Type" value={engineType ?? ''} />
|
||||
<ReviewRow label="Engine Power (kW)" value={String(enginePowerKw)} />
|
||||
<ReviewRow label="Number of Engines" value={String(numberOfEngines)} />
|
||||
<ReviewRow label="Hull Material" value={hullMaterial ?? ''} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Owner Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Owner Name" value={ownerName} />
|
||||
<ReviewRow label="National ID / TIN" value={ownerNationalIdOrTin} />
|
||||
<ReviewRow label="Phone" value={ownerPhone} />
|
||||
<ReviewRow label="Address" value={ownerAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<Stack gap={6}>
|
||||
{docSlots.map((slot) => (
|
||||
<Group key={slot.key} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={active === 0 ? () => navigate('/vessel-registration') : prev}
|
||||
>
|
||||
{active === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={!canNext()}
|
||||
onClick={next}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconAnchor size={16} />}
|
||||
loading={submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit Registration
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,472 +1,428 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Progress,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconAlertCircle,
|
||||
IconFileDescription,
|
||||
IconShieldCheck,
|
||||
IconArrowRight,
|
||||
IconCertificate,
|
||||
IconDownload,
|
||||
IconInfoCircle,
|
||||
IconClockHour4,
|
||||
IconTransferIn,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
STATUS_PROGRESS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useCreateApplicationMutation,
|
||||
useCreateVesselIncidentMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetMyVesselsQuery,
|
||||
} from '@ema-platform/api';
|
||||
import type { Vessel } from '@ema-platform/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
type VesselRegStatus =
|
||||
| "Pending"
|
||||
| "Under Review"
|
||||
| "Approved"
|
||||
| "Rejected"
|
||||
| "Correction Required";
|
||||
type VesselCategory =
|
||||
| "Inland Waterway Vessel"
|
||||
| "Sea-going Vessel (International)";
|
||||
type RenewalStatus = "Valid" | "Due Soon" | "Overdue" | "Not Applicable";
|
||||
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
|
||||
|
||||
interface VesselRegistration {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
category: VesselCategory;
|
||||
vesselType: string;
|
||||
flagState: string;
|
||||
portOfRegistry: string;
|
||||
capacityLabel: "Passenger Capacity" | "Gross Tonnage (GT)";
|
||||
capacityValue: number;
|
||||
vesselLengthM: number;
|
||||
imoOrHullNumber: string;
|
||||
ownerName: string;
|
||||
status: VesselRegStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
remarks: string;
|
||||
renewalStatus: RenewalStatus;
|
||||
expiryDate: string | null;
|
||||
}
|
||||
const mockVesselRegistrations: VesselRegistration = {
|
||||
id: "VR-001",
|
||||
vesselName: "MV Blue Horizon",
|
||||
category: "Sea-going Vessel (International)",
|
||||
vesselType: "Container Ship",
|
||||
flagState: "Ethiopia",
|
||||
portOfRegistry: "Djibouti",
|
||||
capacityLabel: "Gross Tonnage (GT)",
|
||||
capacityValue: 18500,
|
||||
vesselLengthM: 210,
|
||||
imoOrHullNumber: "IMO9384721",
|
||||
ownerName: "Blue Ocean Shipping Ltd.",
|
||||
status: "Approved",
|
||||
submittedDate: "2026-06-10",
|
||||
approvalDate: "2026-06-15",
|
||||
remarks: "Registration approved successfully.",
|
||||
renewalStatus: "Due Soon",
|
||||
expiryDate: "2031-06-15",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: "gray",
|
||||
"Under Review": "yellow",
|
||||
Approved: "teal",
|
||||
Rejected: "red",
|
||||
"Correction Required": "orange",
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
INLAND_WATERWAY: 'Inland Waterway',
|
||||
SEA_GOING: 'Sea-going',
|
||||
};
|
||||
|
||||
// Inland vessel certificates (1)
|
||||
const INLAND_CERTIFICATES = [
|
||||
{
|
||||
label: "Inland Vessel Registration Certificate",
|
||||
description:
|
||||
"Official government registration document for inland waterway operation",
|
||||
},
|
||||
];
|
||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||
REGISTERED: 'green',
|
||||
SUSPENDED: 'orange',
|
||||
DEREGISTERED: 'gray',
|
||||
};
|
||||
|
||||
// Sea-going vessel certificates (4)
|
||||
const SEAGOING_CERTIFICATES = [
|
||||
{
|
||||
label: "Certificate of Nationality",
|
||||
description:
|
||||
"Certifies the vessel's nationality and right to fly the Ethiopian flag",
|
||||
},
|
||||
{
|
||||
label: "Certificate of Ownership",
|
||||
description: "Confirms legal ownership of the vessel",
|
||||
},
|
||||
{
|
||||
label: "Certificate of Registration",
|
||||
description:
|
||||
"Official registration document for international sea-going operation",
|
||||
},
|
||||
{
|
||||
label: "Minimum Safe Manning Certificate",
|
||||
description:
|
||||
"Specifies the minimum crew required for safe operation of the vessel",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requirements list
|
||||
// ---------------------------------------------------------------------------
|
||||
function RequirementItem({ label }: { label: string }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color="blue" variant="light">
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Certificate card (shown after approval)
|
||||
// ---------------------------------------------------------------------------
|
||||
function CertificateCard({
|
||||
label,
|
||||
description,
|
||||
/** US-VES-016: the owner reports an accident or incident on their vessel. */
|
||||
function IncidentModal({
|
||||
vessel,
|
||||
onClose,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
vessel: Vessel | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [occurredAt, setOccurredAt] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [createIncident, { isLoading }] = useCreateVesselIncidentMutation();
|
||||
|
||||
const submit = async () => {
|
||||
if (!vessel) return;
|
||||
try {
|
||||
await createIncident({
|
||||
vesselId: vessel.id,
|
||||
body: {
|
||||
occurredAt,
|
||||
description,
|
||||
...(location ? { location } : {}),
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('Incident recorded');
|
||||
onClose();
|
||||
setOccurredAt('');
|
||||
setLocation('');
|
||||
setDescription('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not record the incident'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light">
|
||||
<IconCertificate size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
fullWidth
|
||||
<Modal
|
||||
opened={Boolean(vessel)}
|
||||
onClose={onClose}
|
||||
title={`Report incident — ${vessel?.name ?? ''}`}
|
||||
centered
|
||||
>
|
||||
Download Certificate
|
||||
<Stack>
|
||||
<TextInput
|
||||
type="date"
|
||||
label="Date of occurrence"
|
||||
required
|
||||
value={occurredAt}
|
||||
onChange={(e) => setOccurredAt(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Location"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="What happened"
|
||||
required
|
||||
minRows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Card>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
disabled={!occurredAt || description.trim().length < 10}
|
||||
onClick={submit}
|
||||
>
|
||||
Record incident
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* The vessel owner's home (US-VES-001…009, 016): registered vessels with
|
||||
* their certificates and renewals, registrations still in flight, and the
|
||||
* entry point into the config-driven registration wizard.
|
||||
*/
|
||||
export function VesselRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [registration, setRegistration] = useState<VesselRegistration | null>(
|
||||
mockVesselRegistrations,
|
||||
const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery();
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [createApplication] = useCreateApplicationMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const [incidentFor, setIncidentFor] = useState<Vessel | null>(null);
|
||||
|
||||
const inFlight = (applications?.items ?? []).filter(
|
||||
(app) =>
|
||||
app.licenseType?.key === REGISTRATION_TYPE_KEY &&
|
||||
!TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
// `/vessel-registrations/my` resolves the owner from the token, so it never
|
||||
// needed a profile id. Gating on one meant anyone who signed up after the
|
||||
// setup wizard was removed — and so had nothing in local storage — silently
|
||||
// never loaded their registration.
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: "/vessel-registrations/my", method: "GET" })
|
||||
.unwrap()
|
||||
.then((data) => setRegistration(data))
|
||||
.catch(() => {
|
||||
/* no registration yet */
|
||||
});
|
||||
}, [fetchTrigger]);
|
||||
const licenseById = new Map(
|
||||
(licenses?.items ?? []).map((license) => [license.id, license]),
|
||||
);
|
||||
|
||||
const certs =
|
||||
registration?.category === "Sea-going Vessel (International)"
|
||||
? SEAGOING_CERTIFICATES
|
||||
: INLAND_CERTIFICATES;
|
||||
async function downloadCertificate(vessel: Vessel) {
|
||||
try {
|
||||
const result = await getCertificateUrl(vessel.licenseId).unwrap();
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
extractErrorMessage(error, 'Could not fetch the certificate'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A renewal is an ordinary application of kind RENEWAL (US-VES-009). */
|
||||
async function renew(vessel: Vessel) {
|
||||
try {
|
||||
const application = await createApplication({
|
||||
licenseType: REGISTRATION_TYPE_KEY,
|
||||
kind: 'RENEWAL',
|
||||
previousLicenseId: vessel.licenseId,
|
||||
}).unwrap();
|
||||
navigate(
|
||||
`/licensing/${REGISTRATION_TYPE_KEY}/applications/${application.id}`,
|
||||
);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not start the renewal'));
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingVessels || loadingApplications) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconAnchor size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Vessel Registration</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Register your vessel with the Ethiopian Maritime Authority
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* ── No registration yet ───────────────────────────────────────── */}
|
||||
{!registration && (
|
||||
<>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="md" mb="lg" wrap="nowrap">
|
||||
<ThemeIcon size={52} radius="xl" color="blue" variant="light">
|
||||
<IconAnchor size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">
|
||||
Register Your Vessel
|
||||
</Text>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Obtain official registration for inland waterway or sea-going
|
||||
vessels
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Divider mb="md" />
|
||||
|
||||
<Text fw={600} fz="sm" mb="xs">
|
||||
Requirements
|
||||
</Text>
|
||||
<Stack gap={6} mb="xl">
|
||||
<RequirementItem label="Proof of Ownership / Bill of Sale" />
|
||||
<RequirementItem label="Builder's Certificate or Technical Specifications" />
|
||||
<RequirementItem label="Valid Insurance Certificate (Hull & Machinery)" />
|
||||
<RequirementItem label="Tax Clearance Certificate" />
|
||||
<RequirementItem label="Vessel Photos (at least 2 clear images)" />
|
||||
<RequirementItem label="IMO Certificate of Registry (sea-going re-registration only)" />
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Vessel Registration</Title>
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<IconAnchor size={18} />}
|
||||
onClick={() => navigate("/vessel-registration/apply")}
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
|
||||
>
|
||||
Start Vessel Registration
|
||||
Register a vessel
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
bg="var(--mantine-color-blue-light)"
|
||||
>
|
||||
<Group gap="xs" mb={4}>
|
||||
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||
<Text fw={600} fz="sm" c="blue.7">
|
||||
About Vessel Registration
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Registration is valid for <strong>5 years</strong> from the date
|
||||
of approval. After approval, inland vessels receive an{" "}
|
||||
<strong>Inland Vessel Registration Certificate</strong>, while
|
||||
sea-going vessels receive four certificates: Certificate of
|
||||
Nationality, Certificate of Ownership, Certificate of
|
||||
Registration, and Minimum Safe Manning Certificate.
|
||||
</Text>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Registration exists ──────────────────────────────────────── */}
|
||||
{registration && (
|
||||
<>
|
||||
{/* Renewal alert */}
|
||||
{registration.renewalStatus === "Due Soon" && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={17} />}
|
||||
color="orange"
|
||||
title="Renewal Due Soon"
|
||||
>
|
||||
Your vessel registration expires on {registration.expiryDate}.
|
||||
Please initiate renewal to avoid expiry.
|
||||
<Button size="xs" variant="white" color="orange" mt="xs">
|
||||
Start Renewal
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
{registration.renewalStatus === "Overdue" && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={17} />}
|
||||
color="red"
|
||||
title="Registration Expired"
|
||||
>
|
||||
Your vessel registration expired on {registration.expiryDate}.
|
||||
Immediate renewal is required.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Status card */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light">
|
||||
<IconAnchor size={22} />
|
||||
</ThemeIcon>
|
||||
{/* ----------------------------------------------------- in-flight */}
|
||||
{inFlight.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>Registrations in progress</Title>
|
||||
{inFlight.map((app) => {
|
||||
const isDraft = app.status === 'DRAFT';
|
||||
const needsAction = app.status === 'RESUBMIT_REQUIRED';
|
||||
const vesselName =
|
||||
(app.formData?.vesselDetails?.vesselName as string) ?? null;
|
||||
return (
|
||||
<Card key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={700} fz="lg">
|
||||
{registration.vesselName}
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
{vesselName && (
|
||||
<Text c="dimmed" size="sm">
|
||||
— {vesselName}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{registration.id}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{app.kind === 'RENEWAL' && (
|
||||
<Badge variant="light">Renewal</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Badge
|
||||
color={STATUS_COLOR[registration.status] ?? "gray"}
|
||||
size="lg"
|
||||
variant="light"
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[app.status]}
|
||||
mt="xs"
|
||||
w={260}
|
||||
/>
|
||||
</div>
|
||||
<Group wrap="nowrap">
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={needsAction ? 'filled' : 'light'}
|
||||
color={needsAction ? 'orange' : undefined}
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{registration.status}
|
||||
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ------------------------------------------------------- register */}
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>My vessels</Title>
|
||||
{(vessels ?? []).length === 0 ? (
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<IconShip size={40} color="var(--mantine-color-blue-5)" />
|
||||
<Text fw={600}>No registered vessels yet</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||
Register an inland-waterway or sea-going vessel. Approval
|
||||
issues the registration certificate and enters the vessel in
|
||||
the national register.
|
||||
</Text>
|
||||
<Button
|
||||
mt="xs"
|
||||
onClick={() =>
|
||||
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)
|
||||
}
|
||||
>
|
||||
Start registration
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Registration №</Table.Th>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Certificate</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(vessels ?? []).map((vessel) => {
|
||||
const license = licenseById.get(vessel.licenseId);
|
||||
const renewable = license?.renewable ?? false;
|
||||
const expiring =
|
||||
license?.daysUntilExpiry !== undefined &&
|
||||
license.daysUntilExpiry <= 60;
|
||||
return (
|
||||
<Table.Tr key={vessel.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{vessel.registrationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{vessel.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{vessel.vesselType ?? '—'}
|
||||
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{license ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={
|
||||
license.status === 'ACTIVE'
|
||||
? expiring
|
||||
? 'yellow'
|
||||
: 'green'
|
||||
: 'red'
|
||||
}
|
||||
>
|
||||
{license.status === 'ACTIVE' && expiring
|
||||
? `Expires in ${license.daysUntilExpiry}d`
|
||||
: license.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{[
|
||||
{ label: "Category", value: registration.category },
|
||||
{ label: "Vessel Type", value: registration.vesselType },
|
||||
{ label: "Flag State", value: registration.flagState },
|
||||
{
|
||||
label: "Port of Registry",
|
||||
value: registration.portOfRegistry,
|
||||
},
|
||||
{
|
||||
label: registration.capacityLabel,
|
||||
value: String(registration.capacityValue),
|
||||
},
|
||||
{ label: "Submitted", value: registration.submittedDate },
|
||||
].map((row) => (
|
||||
<div key={row.label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>
|
||||
{row.label}
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
<Text fz="sm" mt={2}>
|
||||
{row.value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{registration.remarks && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>
|
||||
Officer Remarks
|
||||
</Text>
|
||||
<Text fz="sm">{registration.remarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Timeline / status info */}
|
||||
{registration.status !== "Approved" && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconClockHour4 size={16} />
|
||||
<Text fw={600} fz="sm">
|
||||
Application Status
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: "Submitted", done: true },
|
||||
{
|
||||
label: "Under Review",
|
||||
done: registration.status !== "Pending",
|
||||
},
|
||||
{ label: "Approved", done: false },
|
||||
].map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon
|
||||
size={20}
|
||||
radius="xl"
|
||||
color={step.done ? "teal" : "gray"}
|
||||
variant={step.done ? "filled" : "light"}
|
||||
>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : "dimmed"}>
|
||||
{step.label}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Transfer ownership — only when approved */}
|
||||
{registration.status === "Approved" && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600} fz="sm">
|
||||
Transfer Ownership
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Transfer this vessel to a new owner
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconTransferIn size={15} />}
|
||||
color="violet"
|
||||
variant="light"
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
onClick={() => navigate("/vessel-registration/transfer")}
|
||||
color={VESSEL_STATUS_COLORS[vessel.status]}
|
||||
>
|
||||
Request Transfer
|
||||
{vessel.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="Download certificate">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconCertificate size={14} />}
|
||||
onClick={() => downloadCertificate(vessel)}
|
||||
>
|
||||
Certificate
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{renewable && vessel.status === 'REGISTERED' && (
|
||||
<Tooltip label="Renew the registration">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => renew(vessel)}
|
||||
>
|
||||
Renew
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Report accident / incident">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
leftSection={<IconAlertTriangle size={14} />}
|
||||
onClick={() => setIncidentFor(vessel)}
|
||||
>
|
||||
Incident
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{(vessels ?? []).some((v) => v.status === 'SUSPENDED') && (
|
||||
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
|
||||
A suspended vessel may not operate. Contact the Ethiopian Maritime
|
||||
Authority about reinstatement.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Certificates section — shown after approval */}
|
||||
{registration.status === "Approved" && (
|
||||
<div>
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconShieldCheck
|
||||
size={18}
|
||||
color="var(--mantine-color-teal-6)"
|
||||
/>
|
||||
<Text fw={700} fz="md">
|
||||
{registration.category === "Sea-going Vessel (International)"
|
||||
? "Issued Certificates (4)"
|
||||
: "Issued Certificate"}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs">
|
||||
<IconAnchor size={18} color="var(--mantine-color-blue-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Ownership transfer, amendment and duplicate-certificate services
|
||||
are coming in a later release.
|
||||
</Text>
|
||||
</Group>
|
||||
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||
Your vessel registration has been approved. You may download
|
||||
your certificate(s) below.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{certs.map((cert) => (
|
||||
<CertificateCard
|
||||
key={cert.label}
|
||||
label={cert.label}
|
||||
description={cert.description}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<IncidentModal vessel={incidentFor} onClose={() => setIncidentFor(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselRegistrationPage;
|
||||
|
||||
@@ -1,23 +1,216 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconFileText,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
|
||||
|
||||
/**
|
||||
* Placeholder until waivers have a backend.
|
||||
* Maritime waiver home (US-WAV-001/002/009).
|
||||
*
|
||||
* This page previously ran a full application wizard against
|
||||
* `/logistics-waivers`, an endpoint the API has never exposed: the request
|
||||
* always failed, and the documents the applicant uploaded were never sent
|
||||
* anywhere. Saying nothing is available is more honest than collecting
|
||||
* paperwork into a void.
|
||||
* The two waiver kinds are seeded licence types, so the application itself is
|
||||
* the config-driven wizard and this page only has to be the door: which kind
|
||||
* applies, what is in flight, and the issued letters (US-WAV-007) with their
|
||||
* verifiable references (US-WAV-010).
|
||||
*/
|
||||
export function WaiverPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: applications, isLoading } = useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
const waiverApplications = (applications?.items ?? []).filter((app) =>
|
||||
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||
);
|
||||
const inFlight = waiverApplications.filter(
|
||||
(app) => !TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
const letters = (licenses?.items ?? []).filter((license) =>
|
||||
WAIVER_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
|
||||
);
|
||||
|
||||
async function download(licenseId: string) {
|
||||
try {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not fetch the letter'));
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Waiver applications"
|
||||
description="Waivers are not connected to the backend yet. You will be able to apply here once EMA enables them."
|
||||
/>
|
||||
</Container>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack maw={900} mx="auto">
|
||||
<div>
|
||||
<Title order={2}>Maritime Waiver</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Apply for a waiver when Ethiopian Shipping & Logistics cannot
|
||||
carry your shipment. Approval issues the bank waiver letter.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text fw={600}>Pre-waiver</Text>
|
||||
<Text size="sm" c="dimmed" mt={4} mb="md">
|
||||
The cargo has not yet arrived. Applying before arrival avoids the
|
||||
post-waiver penalty.
|
||||
</Text>
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
|
||||
>
|
||||
Apply for a pre-waiver
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text fw={600}>Post-waiver</Text>
|
||||
<Text size="sm" c="dimmed" mt={4} mb="md">
|
||||
The cargo has already arrived. Granted once per shipment, and only
|
||||
against a settled penalty with the receipt attached.
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
|
||||
>
|
||||
Apply for a post-waiver
|
||||
</Button>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Each waiver covers one shipment, identified by its bill of lading. A
|
||||
second application quoting the same bill of lading is refused while the
|
||||
first is live.
|
||||
</Alert>
|
||||
|
||||
{inFlight.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Applications in progress</Title>
|
||||
{inFlight.map((app) => (
|
||||
<Card key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.licenseType?.name?.en}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Continue'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Issued waiver letters</Title>
|
||||
{letters.length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No waiver letters issued yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{letters.map((license) => (
|
||||
<Table.Tr key={license.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{license.licenseType?.name?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{license.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconFileText size={13} />}
|
||||
onClick={() => download(license.id)}
|
||||
>
|
||||
Letter
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ export const am: Translations = {
|
||||
waiver: 'ነፃ ፈቃድ',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||
seafarerRegistration: 'የባህረኛ ምዝገባ',
|
||||
exams: 'ፈተናዎች',
|
||||
seaRecords: 'የባህር መዝገቦቼ',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
|
||||
@@ -35,6 +35,9 @@ export const en = {
|
||||
myApplications: 'My Applications',
|
||||
dashboard: 'Dashboard',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
seafarerRegistration: 'Seafarer Registration',
|
||||
exams: 'Examinations',
|
||||
seaRecords: 'My Sea Records',
|
||||
myApplication: 'My Application',
|
||||
vesselRegistration: 'Vessel Registration',
|
||||
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
||||
|
||||
@@ -56,70 +56,26 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
{
|
||||
label: "nav.groupLicensing",
|
||||
items: [
|
||||
{
|
||||
to: "/licensing/applications",
|
||||
label: "My Applications",
|
||||
i18nKey: "nav.myApplications",
|
||||
icon: IconTruck,
|
||||
},
|
||||
{
|
||||
to: "/waiver",
|
||||
label: "Waiver",
|
||||
i18nKey: "nav.waiver",
|
||||
icon: IconShieldOff,
|
||||
soon: true,
|
||||
},
|
||||
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck },
|
||||
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "nav.groupSeafarer",
|
||||
items: [
|
||||
{
|
||||
to: "/seafarer-registry",
|
||||
label: "Seafarer Registry",
|
||||
i18nKey: "nav.seafarerRegistry",
|
||||
icon: IconList,
|
||||
},
|
||||
{
|
||||
to: "/seaman-book",
|
||||
label: "Seaman Book",
|
||||
i18nKey: "nav.myApplication",
|
||||
icon: IconSend,
|
||||
soon: true,
|
||||
},
|
||||
{
|
||||
to: "/certificates",
|
||||
label: "Certificates",
|
||||
i18nKey: "nav.certificates",
|
||||
icon: IconShieldCheck,
|
||||
soon: true,
|
||||
},
|
||||
{
|
||||
to: "/endorsements",
|
||||
label: "Endorsements",
|
||||
i18nKey: "nav.endorsements",
|
||||
icon: IconRubberStamp,
|
||||
soon: true,
|
||||
},
|
||||
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList },
|
||||
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList },
|
||||
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true },
|
||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
||||
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList },
|
||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "nav.groupVessels",
|
||||
items: [
|
||||
{
|
||||
to: "/vessel-registration",
|
||||
label: "Vessel Registration",
|
||||
i18nKey: "nav.vesselRegistration",
|
||||
icon: IconShip,
|
||||
soon: true,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration/transfer",
|
||||
label: "Ownership Transfer",
|
||||
i18nKey: "nav.ownershipTransfer",
|
||||
icon: IconArrowsExchange,
|
||||
soon: true,
|
||||
},
|
||||
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
|
||||
{ to: '/vessel-registration/transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -148,22 +104,22 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
];
|
||||
|
||||
const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||
"/dashboard": { i18nKey: "nav.dashboard" },
|
||||
"/vessel-registration-dashboard": {
|
||||
i18nKey: "nav.vesselRegistrationDashboard",
|
||||
},
|
||||
"/vessel-registration": { i18nKey: "nav.vesselRegistration" },
|
||||
"/vessel-registration/transfer": { i18nKey: "nav.ownershipTransfer" },
|
||||
"/licensing/applications": { i18nKey: "nav.myApplications" },
|
||||
"/waiver": { i18nKey: "nav.waiver" },
|
||||
"/seafarer-registry": { i18nKey: "nav.seafarerRegistry" },
|
||||
"/seaman-book": { i18nKey: "nav.myApplication" },
|
||||
"/certificates": { i18nKey: "nav.certificates" },
|
||||
"/endorsements": { i18nKey: "nav.endorsements" },
|
||||
"/documents": { i18nKey: "nav.documents" },
|
||||
"/notifications": { i18nKey: "nav.notifications" },
|
||||
"/profile": { i18nKey: "nav.profile" },
|
||||
"/support": { i18nKey: "nav.support" },
|
||||
'/dashboard': { i18nKey: 'nav.dashboard' },
|
||||
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
|
||||
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
|
||||
'/vessel-registration/transfer': { i18nKey: 'nav.ownershipTransfer' },
|
||||
'/licensing/applications': { i18nKey: 'nav.myApplications' },
|
||||
'/waiver': { i18nKey: 'nav.waiver' },
|
||||
'/seafarer-registration': { i18nKey: 'nav.seafarerRegistration' },
|
||||
'/seafarer/records': { i18nKey: 'nav.seaRecords' },
|
||||
'/seaman-book': { i18nKey: 'nav.myApplication' },
|
||||
'/certificates': { i18nKey: 'nav.certificates' },
|
||||
'/exams': { i18nKey: 'nav.exams' },
|
||||
'/endorsements': { i18nKey: 'nav.endorsements' },
|
||||
'/documents': { i18nKey: 'nav.documents' },
|
||||
'/notifications':{ i18nKey: 'nav.notifications' },
|
||||
'/profile': { i18nKey: 'nav.profile' },
|
||||
'/support': { i18nKey: 'nav.support' },
|
||||
};
|
||||
|
||||
export function PortalLayout() {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconAnchor, IconHome2, IconTransferIn, IconUserCircle } from '@tabler/icons-react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem } from '@ema-platform/ui';
|
||||
import { BrandMark, logout } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
|
||||
// Only vessel-registration routes are exposed to vessel owners
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/vessel-owner/dashboard', label: 'My Vessels', icon: IconHome2 },
|
||||
{ to: '/vessel-registration', label: 'Vessel Registration', icon: IconAnchor },
|
||||
{ to: '/vessel-owner/registration/transfer', label: 'Ownership Transfer', icon: IconTransferIn },
|
||||
{ to: '/vessel-owner/profile', label: 'My Profile', icon: IconUserCircle },
|
||||
];
|
||||
|
||||
export function VesselOwnerLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
|
||||
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
|
||||
|
||||
const displayName = user?.name?.en || user?.username || 'Vessel Owner';
|
||||
const initials = displayName.split(/\s+/).map((s: string) => s[0]).join('').toUpperCase().slice(0, 2) || 'VO';
|
||||
|
||||
const go = (item: NavItem) => {
|
||||
if (item.soon) { notify.info(`${item.label} — coming soon.`); return; }
|
||||
if (item.to) { navigate(item.to); closeNav(); }
|
||||
};
|
||||
|
||||
const handleLogout = () => { dispatch(logout()); navigate('/vessel-owner/login'); };
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 74 }}
|
||||
navbar={{ width: sidebarCollapsed ? 72 : 264, breakpoint: 'sm', collapsed: { mobile: !navOpened } }}
|
||||
padding="lg"
|
||||
>
|
||||
<AppShell.Header style={{ background: 'var(--mantine-color-body)', borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
|
||||
<AppHeader
|
||||
onToggleNav={toggleNav}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
navOpened={navOpened}
|
||||
breadcrumbs={[{ label: 'Vessel Owner Portal', path: '/vessel-owner/dashboard' }]}
|
||||
onNavigate={navigate}
|
||||
onLogout={handleLogout}
|
||||
userName={displayName}
|
||||
userInitials={initials}
|
||||
supportedLanguages={SUPPORTED_LANGUAGES}
|
||||
/>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p={0} style={{ overflow: 'hidden', transition: 'width 200ms ease', background: 'var(--mantine-color-body)', borderRight: '1px solid var(--mantine-color-gray-2)' }}>
|
||||
<AppSidebar
|
||||
navItems={NAV_ITEMS}
|
||||
collapsed={sidebarCollapsed}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={toggleSidebar}
|
||||
onNavigate={go}
|
||||
brandName="Vessel Owner Portal"
|
||||
brandSubtitle="EMAA"
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<div key={location.pathname} className="ema-page-enter">
|
||||
<Outlet />
|
||||
</div>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { PortalLayout } from './layouts/PortalLayout';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage, SetPasswordPage } from '@ema-platform/auth';
|
||||
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
@@ -14,8 +14,9 @@ import { OperationsOnboardingPage } from './features/onboarding/pages/Operations
|
||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||
import { SupportPage } from './features/support/pages/SupportPage';
|
||||
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
||||
import { SeafarerRegistryPage } from './features/seafarer/pages/SeafarerRegistryPage';
|
||||
import { SeafarerProfilePage } from './features/seafarer/pages/SeafarerProfilePage';
|
||||
import { MySeaRecordsPage } from './features/seafarer/pages/MySeaRecordsPage';
|
||||
import { VerifyCertificatePage } from './features/verify/pages/VerifyCertificatePage';
|
||||
import { ExamsPage } from './features/exams/pages/ExamsPage';
|
||||
|
||||
// Phase 1 pages
|
||||
import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage';
|
||||
@@ -30,13 +31,7 @@ import { CoCApplicationPage } from './features/certificates/pages/CoCApplication
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
||||
import { VesselRegistrationPage } from './features/vessel-registration/pages/VesselRegistrationPage';
|
||||
import { VesselRegistrationApplicationPage } from './features/vessel-registration/pages/VesselRegistrationApplicationPage';
|
||||
import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage';
|
||||
import { VesselRegistrationDashboardPage } from './features/vessel-registration-dashboard/pages/VesselRegistrationDashboardPage';
|
||||
import { VesselOwnerLayout } from './layouts/VesselOwnerLayout';
|
||||
import { VesselOwnerLoginPage } from './features/vessel-owner/pages/VesselOwnerLoginPage';
|
||||
import { VesselOwnerRegisterPage } from './features/vessel-owner/pages/VesselOwnerRegisterPage';
|
||||
import { VesselOwnerDashboardPage } from './features/vessel-owner/pages/VesselOwnerDashboardPage';
|
||||
import { MyApplicationsPage } from './features/licensing/pages/MyApplicationsPage';
|
||||
import { PaymentCheckPage } from './features/payments/pages/PaymentCheckPage';
|
||||
import { PaymentSuccessPage } from './features/payments/pages/PaymentSuccessPage';
|
||||
@@ -54,6 +49,17 @@ export const router = createBrowserRouter([
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/signup', element: <SignupPage /> },
|
||||
|
||||
// Public certificate verification — the target of every printed QR code.
|
||||
// No auth: a verifier scanning a certificate has no portal account.
|
||||
{ path: '/verify', element: <VerifyCertificatePage /> },
|
||||
{ path: '/verify/:code', element: <VerifyCertificatePage /> },
|
||||
|
||||
// Completes the forgot-password flow; the reset message links here. The
|
||||
// IAM package generates `/reset-password` links, `/set-password` is the
|
||||
// first-time-credential variant — one page serves both.
|
||||
{ path: '/set-password', element: <SetPasswordPage /> },
|
||||
{ path: '/reset-password', element: <SetPasswordPage /> },
|
||||
|
||||
// Protected auth pages
|
||||
{
|
||||
element: <ProtectedRoute><OTPVerificationPage /></ProtectedRoute>,
|
||||
@@ -105,8 +111,12 @@ export const router = createBrowserRouter([
|
||||
|
||||
// Seafarer
|
||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
||||
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
|
||||
{ path: '/seafarer/records', element: <MySeaRecordsPage /> },
|
||||
{ path: '/exams', element: <ExamsPage /> },
|
||||
// The public-facing registry was a hardcoded mock and does not belong in
|
||||
// the applicant portal; officers browse seafarers in the backoffice.
|
||||
{ path: '/seafarer-registry', element: <Navigate to="/seafarer-registration" replace /> },
|
||||
{ path: '/seafarer-registry/:id', element: <Navigate to="/seafarer-registration" replace /> },
|
||||
|
||||
// Phase 1
|
||||
{ path: '/documents', element: <DocumentVaultPage /> },
|
||||
@@ -120,9 +130,11 @@ export const router = createBrowserRouter([
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
{ path: '/endorsements', element: <EndorsementPage /> },
|
||||
{ path: '/vessel-registration-dashboard', element: <VesselRegistrationDashboardPage /> },
|
||||
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
|
||||
{ path: '/vessel-registration/apply', element: <VesselRegistrationApplicationPage /> },
|
||||
// The registration wizard is the config-driven licensing flow; the old
|
||||
// standalone wizard posted to endpoints that never existed.
|
||||
{ path: '/vessel-registration/apply', element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace /> },
|
||||
{ path: '/vessel-registration-dashboard', element: <Navigate to="/vessel-registration" replace /> },
|
||||
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
|
||||
// Legacy per-licence-type URLs. Each once had its own hand-written page
|
||||
// that posted to a `/logistics-licenses/*` endpoint the API never had,
|
||||
@@ -161,23 +173,14 @@ export const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
|
||||
{ path: '/vessel-owner/login', element: <VesselOwnerLoginPage /> },
|
||||
{ path: '/vessel-owner/register', element: <VesselOwnerRegisterPage /> },
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<VesselOwnerLayout />
|
||||
</I18nextProvider>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ path: '/vessel-owner/dashboard', element: <VesselOwnerDashboardPage /> },
|
||||
{ path: '/vessel-owner/registration', element: <VesselRegistrationPage /> },
|
||||
{ path: '/vessel-owner/registration/apply', element: <VesselRegistrationApplicationPage /> },
|
||||
{ path: '/vessel-owner/registration/transfer', element: <OwnershipTransferPage /> },
|
||||
],
|
||||
},
|
||||
// The separate vessel-owner login/portal was mock-only and called auth
|
||||
// endpoints that never existed; vessel owners are ordinary portal users.
|
||||
{ path: '/vessel-owner/login', element: <Navigate to="/login" replace /> },
|
||||
{ path: '/vessel-owner/register', element: <Navigate to="/signup" replace /> },
|
||||
{ path: '/vessel-owner/dashboard', element: <Navigate to="/vessel-registration" replace /> },
|
||||
{ path: '/vessel-owner/registration', element: <Navigate to="/vessel-registration" replace /> },
|
||||
{ path: '/vessel-owner/registration/apply', element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace /> },
|
||||
{ path: '/vessel-owner/registration/transfer', element: <Navigate to="/vessel-registration/transfer" replace /> },
|
||||
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export * from "./lib/base-api";
|
||||
export * from "./lib/query-and-mutation";
|
||||
export * from "./lib/session";
|
||||
export * from "./lib/features/licensing";
|
||||
export * from "./lib/features/licensing/licensing-api";
|
||||
export {
|
||||
baseQueryWithReauth,
|
||||
configureTokenRefresh,
|
||||
} from "./lib/base-api/base-query-with-reauth";
|
||||
export * from './lib/base-api';
|
||||
export * from './lib/query-and-mutation';
|
||||
export * from './lib/session';
|
||||
export * from './lib/features/licensing';
|
||||
export * from './lib/features/seafarer';
|
||||
export * from './lib/features/vessel';
|
||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
export { openAuthedDocument } from './lib/base-api/download';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react';
|
||||
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||
import { resolveSessionContext } from '../session';
|
||||
|
||||
const BASE_API_URL =
|
||||
export const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3001/api';
|
||||
|
||||
|
||||
43
libs/api/src/lib/base-api/download.ts
Normal file
43
libs/api/src/lib/base-api/download.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { resolveTokenFromStorage } from '../session';
|
||||
import { BASE_API_URL } from './base-query-with-reauth';
|
||||
|
||||
/**
|
||||
* Opens an authenticated binary endpoint (a rendered PDF) in a new tab.
|
||||
*
|
||||
* RTK Query is not used here: `fetchBaseQuery` parses responses as JSON, and
|
||||
* a plain `window.open` sends no Authorization header, so a guarded document
|
||||
* endpoint would answer 401. Fetching to a blob keeps the bearer token on the
|
||||
* request and still gives the browser something it can display.
|
||||
*/
|
||||
export async function openAuthedDocument(
|
||||
path: string,
|
||||
fallbackName = 'document.pdf',
|
||||
): Promise<void> {
|
||||
const token = resolveTokenFromStorage();
|
||||
const response = await fetch(`${BASE_API_URL}${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// The body carries the API's error key, which callers surface verbatim.
|
||||
let message = `${response.status}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
message = body?.message ?? message;
|
||||
} catch {
|
||||
/* non-JSON error body — the status is all we have */
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const opened = window.open(url, '_blank', 'noopener');
|
||||
if (!opened) {
|
||||
// Pop-up blocked: fall back to a direct download so the click still works.
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fallbackName;
|
||||
anchor.click();
|
||||
}
|
||||
// Revoking immediately would race the new tab's load.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
@@ -308,6 +308,39 @@ export const licensingApi = baseApi
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------ licences
|
||||
/**
|
||||
* Public QR-code verification (US-PORTAL-008). No auth required —
|
||||
* the endpoint returns only what a verifier needs to trust the
|
||||
* document, never the holder's contact details.
|
||||
*/
|
||||
verifyCertificate: builder.query<
|
||||
{
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
certificateNumber?: string;
|
||||
licenseType?: string | null;
|
||||
companyName?: string | null;
|
||||
issueDate?: string;
|
||||
expiryDate?: string;
|
||||
status?: string;
|
||||
},
|
||||
string
|
||||
>({
|
||||
query: (code) => ({ url: `/licenses/verify/${code}` }),
|
||||
}),
|
||||
|
||||
/** The licence register for enforcement officers. */
|
||||
getLicenses: builder.query<
|
||||
Paginated<IssuedLicense>,
|
||||
{ search?: string } | void
|
||||
>({
|
||||
query: (args) => ({
|
||||
url: '/licenses',
|
||||
params: args?.search ? { search: args.search } : undefined,
|
||||
}),
|
||||
providesTags: () => [listTag('License')],
|
||||
}),
|
||||
|
||||
getMyLicenses: builder.query<Paginated<IssuedLicense>, void>({
|
||||
query: () => ({ url: '/licenses/mine' }),
|
||||
providesTags: () => [listTag('License')],
|
||||
@@ -693,12 +726,24 @@ export const licensingApi = baseApi
|
||||
|
||||
recordInspectionResult: builder.mutation<
|
||||
Inspection,
|
||||
{ inspectionId: string; applicationId: string; result: 'PASSED' | 'FAILED'; findings: string }
|
||||
{
|
||||
inspectionId: string;
|
||||
applicationId: string;
|
||||
result: 'PASSED' | 'FAILED';
|
||||
findings: string;
|
||||
/** Structured per-item outcomes (office, warehouse, vehicles, …). */
|
||||
checklist?: {
|
||||
key: string;
|
||||
label: string;
|
||||
outcome: 'PASS' | 'FAIL' | 'NEEDS_CORRECTION';
|
||||
note?: string;
|
||||
}[];
|
||||
}
|
||||
>({
|
||||
query: ({ inspectionId, result, findings }) => ({
|
||||
query: ({ inspectionId, result, findings, checklist }) => ({
|
||||
url: `/inspections/${inspectionId}/result`,
|
||||
method: 'PATCH',
|
||||
body: { result, findings },
|
||||
body: { result, findings, ...(checklist?.length ? { checklist } : {}) },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||
@@ -724,6 +769,7 @@ export const licensingApi = baseApi
|
||||
});
|
||||
|
||||
export const {
|
||||
useVerifyCertificateQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
@@ -736,6 +782,7 @@ export const {
|
||||
useBypassPaymentMutation,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetLicensesQuery,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetApplicationPaymentQuery,
|
||||
usePatchSectionMutation,
|
||||
|
||||
@@ -18,7 +18,13 @@ const BASE_API_URL =
|
||||
* attach them to, and nothing can be lost if the browser closes mid-wizard.
|
||||
*/
|
||||
export async function uploadDocument(params: {
|
||||
ownerType: 'APPLICATION' | 'APPLICATION_STAFF' | 'INSPECTION' | 'LICENSE';
|
||||
ownerType:
|
||||
| 'APPLICATION'
|
||||
| 'APPLICATION_STAFF'
|
||||
| 'INSPECTION'
|
||||
| 'LICENSE'
|
||||
| 'SEA_SERVICE_RECORD'
|
||||
| 'MEDICAL_CERTIFICATE';
|
||||
ownerId: string;
|
||||
documentKey: string;
|
||||
file: File;
|
||||
@@ -210,6 +216,14 @@ export interface WizardStep {
|
||||
export function buildWizardSteps(
|
||||
sections: FormSectionConfig[],
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
options?: {
|
||||
/**
|
||||
* Whether this licence type has staff-role requirements at all. Types
|
||||
* without any (seafarer registration) skip the Staff step entirely
|
||||
* instead of showing an empty page.
|
||||
*/
|
||||
hasStaff?: boolean;
|
||||
},
|
||||
): WizardStep[] {
|
||||
const visible = [...sections]
|
||||
.filter((section) => conditionHolds(section.showWhen, formData))
|
||||
@@ -259,7 +273,9 @@ export function buildWizardSteps(
|
||||
|
||||
return [
|
||||
...formSteps,
|
||||
{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] },
|
||||
...(options?.hasStaff === false
|
||||
? []
|
||||
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
|
||||
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
|
||||
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
|
||||
];
|
||||
|
||||
@@ -75,7 +75,10 @@ export interface FormSectionConfig {
|
||||
|
||||
/** Grouping the portal organises the licence catalogue by. */
|
||||
export type LicenseCategory =
|
||||
"CARGO_FREIGHT" | "SHIPPING_AGENCY" | "INVESTMENT";
|
||||
| 'CARGO_FREIGHT'
|
||||
| 'SHIPPING_AGENCY'
|
||||
| 'INVESTMENT'
|
||||
| 'MARITIME_PERSONNEL';
|
||||
|
||||
export interface LicenseCategoryDefinition {
|
||||
key: LicenseCategory;
|
||||
@@ -117,6 +120,12 @@ export interface LicenseType {
|
||||
inspectionRequired: boolean;
|
||||
issuesCertificate: boolean;
|
||||
renewalEnabled: boolean;
|
||||
/**
|
||||
* False for person-centric registrations (seafarer): they are open to any
|
||||
* authenticated applicant, live outside the operator catalogue, and have
|
||||
* their own entry points.
|
||||
*/
|
||||
requiresOperatorMode: boolean;
|
||||
formSchema: { sections: FormSectionConfig[] };
|
||||
isActive: boolean;
|
||||
/** Display order set by EMA; lower comes first. */
|
||||
|
||||
2
libs/api/src/lib/features/seafarer/index.ts
Normal file
2
libs/api/src/lib/features/seafarer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './seafarer.types';
|
||||
export * from './seafarer-api';
|
||||
188
libs/api/src/lib/features/seafarer/seafarer-api.ts
Normal file
188
libs/api/src/lib/features/seafarer/seafarer-api.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type {
|
||||
CreateMedicalCertificate,
|
||||
CreateSeaServiceRecord,
|
||||
MedicalCertificate,
|
||||
SeaServiceRecord,
|
||||
SeaTimeSummary,
|
||||
SeafarerStatus,
|
||||
} from './seafarer.types';
|
||||
|
||||
const TAGS = ['SeaServiceRecord', 'MedicalCertificate'] as const;
|
||||
|
||||
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`.
|
||||
*/
|
||||
export const seafarerApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: TAGS })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
// ---------------------------------------------------------- sea service
|
||||
getMySeaServiceRecords: builder.query<SeaServiceRecord[], void>({
|
||||
query: () => ({ url: '/sea-service-records/mine' }),
|
||||
providesTags: () => [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
getSeaServiceForProfile: builder.query<SeaServiceRecord[], string>({
|
||||
query: (profileId) => ({
|
||||
url: `/sea-service-records/profile/${profileId}`,
|
||||
}),
|
||||
providesTags: () => [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
createSeaServiceRecord: builder.mutation<
|
||||
SeaServiceRecord,
|
||||
CreateSeaServiceRecord
|
||||
>({
|
||||
query: (body) => ({ url: '/sea-service-records', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
updateSeaServiceRecord: builder.mutation<
|
||||
SeaServiceRecord,
|
||||
{ id: string; body: Partial<CreateSeaServiceRecord> }
|
||||
>({
|
||||
query: ({ id, body }) => ({
|
||||
url: `/sea-service-records/${id}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
deleteSeaServiceRecord: builder.mutation<unknown, string>({
|
||||
query: (id) => ({ url: `/sea-service-records/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------------- medical
|
||||
getMyMedicalCertificates: builder.query<MedicalCertificate[], void>({
|
||||
query: () => ({ url: '/medical-certificates/mine' }),
|
||||
providesTags: () => [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
getMedicalForProfile: builder.query<MedicalCertificate[], string>({
|
||||
query: (profileId) => ({
|
||||
url: `/medical-certificates/profile/${profileId}`,
|
||||
}),
|
||||
providesTags: () => [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
createMedicalCertificate: builder.mutation<
|
||||
MedicalCertificate,
|
||||
CreateMedicalCertificate
|
||||
>({
|
||||
query: (body) => ({ url: '/medical-certificates', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
updateMedicalCertificate: builder.mutation<
|
||||
MedicalCertificate,
|
||||
{ id: string; body: Partial<CreateMedicalCertificate> }
|
||||
>({
|
||||
query: ({ id, body }) => ({
|
||||
url: `/medical-certificates/${id}`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
deleteMedicalCertificate: builder.mutation<unknown, string>({
|
||||
query: (id) => ({ url: `/medical-certificates/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------- verification
|
||||
getPendingSeaService: builder.query<SeaServiceRecord[], void>({
|
||||
query: () => ({ url: '/sea-service-records/pending' }),
|
||||
providesTags: () => [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
getPendingMedical: builder.query<MedicalCertificate[], void>({
|
||||
query: () => ({ url: '/medical-certificates/pending' }),
|
||||
providesTags: () => [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
verifySeaServiceRecord: builder.mutation<
|
||||
SeaServiceRecord,
|
||||
{ id: string; outcome: 'VERIFIED' | 'REJECTED'; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/sea-service-records/${id}/verify`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
verifyMedicalCertificate: builder.mutation<
|
||||
MedicalCertificate,
|
||||
{ id: string; outcome: 'VERIFIED' | 'REJECTED'; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/medical-certificates/${id}/verify`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
getMySeaTime: builder.query<SeaTimeSummary, void>({
|
||||
query: () => ({ url: '/sea-service-records/mine/sea-time' }),
|
||||
providesTags: () => [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
getSeaTimeForProfile: builder.query<SeaTimeSummary, string>({
|
||||
query: (profileId) => ({
|
||||
url: `/sea-service-records/profile/${profileId}/sea-time`,
|
||||
}),
|
||||
providesTags: () => [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
// ----------------------------------------------- registry (backoffice)
|
||||
/** US-SEA-013: suspend, reinstate or close with a mandatory reason. */
|
||||
updateSeafarerStatus: builder.mutation<
|
||||
unknown,
|
||||
{ profileId: string; status: SeafarerStatus; reason: string }
|
||||
>({
|
||||
query: ({ profileId, ...body }) => ({
|
||||
url: `/profiles/${profileId}/seafarer-status`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetPendingSeaServiceQuery,
|
||||
useGetPendingMedicalQuery,
|
||||
useVerifySeaServiceRecordMutation,
|
||||
useVerifyMedicalCertificateMutation,
|
||||
useGetMySeaTimeQuery,
|
||||
useGetSeaTimeForProfileQuery,
|
||||
useGetMySeaServiceRecordsQuery,
|
||||
useGetSeaServiceForProfileQuery,
|
||||
useCreateSeaServiceRecordMutation,
|
||||
useUpdateSeaServiceRecordMutation,
|
||||
useDeleteSeaServiceRecordMutation,
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
useGetMedicalForProfileQuery,
|
||||
useCreateMedicalCertificateMutation,
|
||||
useUpdateMedicalCertificateMutation,
|
||||
useDeleteMedicalCertificateMutation,
|
||||
useUpdateSeafarerStatusMutation,
|
||||
} = seafarerApi;
|
||||
78
libs/api/src/lib/features/seafarer/seafarer.types.ts
Normal file
78
libs/api/src/lib/features/seafarer/seafarer.types.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type SeafarerRecordStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED';
|
||||
export type MedicalFitness = 'FIT' | 'FIT_WITH_RESTRICTIONS' | 'UNFIT';
|
||||
export type SeafarerDepartment = 'DECK' | 'ENGINE' | 'CATERING';
|
||||
export type SeafarerStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED';
|
||||
|
||||
/** One engagement aboard one vessel (US-SSM-001). */
|
||||
/** Denormalised owner summary, present on the verification queues. */
|
||||
export interface SeafarerProfileSummary {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
middleName: string | null;
|
||||
lastName: string | null;
|
||||
seafarerNumber?: string | null;
|
||||
}
|
||||
|
||||
export interface SeaServiceRecord {
|
||||
id: string;
|
||||
profileId: string;
|
||||
profile?: SeafarerProfileSummary;
|
||||
vesselName: string;
|
||||
imoNumber: string | null;
|
||||
vesselType: string | null;
|
||||
flagState: string | null;
|
||||
grossTonnage: string | number | null;
|
||||
rank: string;
|
||||
engagementDate: string;
|
||||
dischargeDate: string;
|
||||
dutiesDescription: string | null;
|
||||
status: SeafarerRecordStatus;
|
||||
verifiedById: string | null;
|
||||
verifiedAt: string | null;
|
||||
verificationRemark: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSeaServiceRecord {
|
||||
vesselName: string;
|
||||
imoNumber?: string;
|
||||
vesselType?: string;
|
||||
flagState?: string;
|
||||
grossTonnage?: number;
|
||||
rank: string;
|
||||
engagementDate: string;
|
||||
dischargeDate: string;
|
||||
dutiesDescription?: string;
|
||||
}
|
||||
|
||||
/** An STCW medical fitness certificate (US-SSM-006). */
|
||||
export interface MedicalCertificate {
|
||||
id: string;
|
||||
profileId: string;
|
||||
profile?: SeafarerProfileSummary;
|
||||
issuerName: string;
|
||||
certificateNumber: string | null;
|
||||
issueDate: string;
|
||||
expiryDate: string;
|
||||
fitnessStatus: MedicalFitness;
|
||||
restrictions: string | null;
|
||||
status: SeafarerRecordStatus;
|
||||
verifiedById: string | null;
|
||||
verifiedAt: string | null;
|
||||
verificationRemark: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateMedicalCertificate {
|
||||
issuerName: string;
|
||||
certificateNumber?: string;
|
||||
issueDate: string;
|
||||
expiryDate: string;
|
||||
fitnessStatus?: MedicalFitness;
|
||||
restrictions?: string;
|
||||
}
|
||||
|
||||
export interface SeaTimeSummary {
|
||||
totalDays: number;
|
||||
verifiedRecords: number;
|
||||
}
|
||||
2
libs/api/src/lib/features/vessel/index.ts
Normal file
2
libs/api/src/lib/features/vessel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './vessel.types';
|
||||
export * from './vessel-api';
|
||||
84
libs/api/src/lib/features/vessel/vessel-api.ts
Normal file
84
libs/api/src/lib/features/vessel/vessel-api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type {
|
||||
CreateVesselIncident,
|
||||
Vessel,
|
||||
VesselIncident,
|
||||
VesselStatus,
|
||||
} from './vessel.types';
|
||||
|
||||
const TAGS = ['Vessel', 'VesselIncident'] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
|
||||
/**
|
||||
* The vessel register (module 11). Registration applications themselves go
|
||||
* through `licensingApi` as the VESSEL_REGISTRATION type; these endpoints
|
||||
* serve the register the certificates produce.
|
||||
*/
|
||||
export const vesselApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: TAGS })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getMyVessels: builder.query<Vessel[], void>({
|
||||
query: () => ({ url: '/vessels/mine' }),
|
||||
providesTags: () => [listTag('Vessel')],
|
||||
}),
|
||||
|
||||
getVessels: builder.query<
|
||||
{ total: number; items: Vessel[] },
|
||||
{ search?: string } | void
|
||||
>({
|
||||
query: (args) => ({
|
||||
url: '/vessels',
|
||||
params: args?.search ? { search: args.search } : undefined,
|
||||
}),
|
||||
providesTags: () => [listTag('Vessel')],
|
||||
}),
|
||||
|
||||
getVessel: builder.query<Vessel, string>({
|
||||
query: (id) => ({ url: `/vessels/${id}` }),
|
||||
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
|
||||
}),
|
||||
|
||||
/** Suspend / deregister / reinstate with a mandatory reason. */
|
||||
updateVesselStatus: builder.mutation<
|
||||
Vessel,
|
||||
{ vesselId: string; status: VesselStatus; reason: string }
|
||||
>({
|
||||
query: ({ vesselId, ...body }) => ({
|
||||
url: `/vessels/${vesselId}/status`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { vesselId }) =>
|
||||
error ? [] : [listTag('Vessel'), { type: 'Vessel', id: vesselId }],
|
||||
}),
|
||||
|
||||
getVesselIncidents: builder.query<VesselIncident[], string>({
|
||||
query: (vesselId) => ({ url: `/vessels/${vesselId}/incidents` }),
|
||||
providesTags: () => [listTag('VesselIncident')],
|
||||
}),
|
||||
|
||||
createVesselIncident: builder.mutation<
|
||||
VesselIncident,
|
||||
{ vesselId: string; body: CreateVesselIncident }
|
||||
>({
|
||||
query: ({ vesselId, body }) => ({
|
||||
url: `/vessels/${vesselId}/incidents`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('VesselIncident')],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMyVesselsQuery,
|
||||
useGetVesselsQuery,
|
||||
useGetVesselQuery,
|
||||
useUpdateVesselStatusMutation,
|
||||
useGetVesselIncidentsQuery,
|
||||
useCreateVesselIncidentMutation,
|
||||
} = vesselApi;
|
||||
51
libs/api/src/lib/features/vessel/vessel.types.ts
Normal file
51
libs/api/src/lib/features/vessel/vessel.types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type VesselCategory = 'INLAND_WATERWAY' | 'SEA_GOING';
|
||||
export type VesselStatus = 'REGISTERED' | 'SUSPENDED' | 'DEREGISTERED';
|
||||
|
||||
/** A vessel-register entry, written when a registration certificate issues. */
|
||||
export interface Vessel {
|
||||
id: string;
|
||||
registrationNumber: string;
|
||||
name: string;
|
||||
category: VesselCategory;
|
||||
vesselType: string | null;
|
||||
imoNumber: string | null;
|
||||
hullNumber: string | null;
|
||||
flagState: string | null;
|
||||
portOfRegistry: string | null;
|
||||
grossTonnage: string | number | null;
|
||||
passengerCapacity: number | null;
|
||||
lengthMeters: string | number | null;
|
||||
yearBuilt: number | null;
|
||||
engineType: string | null;
|
||||
enginePowerKw: string | number | null;
|
||||
numberOfEngines: number | null;
|
||||
hullMaterial: string | null;
|
||||
ownerUserId: string;
|
||||
ownerProfileId: string | null;
|
||||
ownerName: string | null;
|
||||
applicationId: string;
|
||||
licenseId: string;
|
||||
status: VesselStatus;
|
||||
statusReason: string | null;
|
||||
statusChangedAt: string | null;
|
||||
registeredAt: string;
|
||||
}
|
||||
|
||||
export interface VesselIncident {
|
||||
id: string;
|
||||
vesselId: string;
|
||||
occurredAt: string;
|
||||
location: string | null;
|
||||
description: string;
|
||||
severity: string | null;
|
||||
reportedById: string;
|
||||
reportedByOfficer: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateVesselIncident {
|
||||
occurredAt: string;
|
||||
location?: string;
|
||||
description: string;
|
||||
severity?: string;
|
||||
}
|
||||
@@ -1,30 +1,17 @@
|
||||
export { AuthConfigProvider, useAuthConfig } from "./lib/AuthConfig";
|
||||
export type { AuthConfigValue } from "./lib/AuthConfig";
|
||||
export { AuthShell, BrandMark } from "./lib/components/AuthShell";
|
||||
export { ProtectedRoute } from "./lib/components/ProtectedRoute";
|
||||
export { AuthBootstrap } from "./lib/components/AuthBootstrap";
|
||||
export { LoginPage } from "./lib/pages/LoginPage";
|
||||
export { SignupPage } from "./lib/pages/SignupPage";
|
||||
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
|
||||
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";
|
||||
export {
|
||||
authReducer,
|
||||
loginSuccess,
|
||||
setUser,
|
||||
setCurrentProfile,
|
||||
clearCurrentProfile,
|
||||
logout,
|
||||
hydrateAuth,
|
||||
setToken,
|
||||
} from "./lib/store/auth.slice";
|
||||
export {
|
||||
signupReducer,
|
||||
setSignupData,
|
||||
setSignupStep,
|
||||
resetSignup,
|
||||
} from "./lib/store/signup.slice";
|
||||
export { usePermissions } from "./lib/hooks/usePermissions";
|
||||
export type { PermissionSet } from "./lib/hooks/usePermissions";
|
||||
export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
|
||||
export type { AuthConfigValue } from './lib/AuthConfig';
|
||||
export { AuthShell, BrandMark } from './lib/components/AuthShell';
|
||||
export { ProtectedRoute } from './lib/components/ProtectedRoute';
|
||||
export { AuthBootstrap } from './lib/components/AuthBootstrap';
|
||||
export { LoginPage } from './lib/pages/LoginPage';
|
||||
export { SignupPage } from './lib/pages/SignupPage';
|
||||
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
|
||||
export { SetPasswordPage } from './lib/pages/SetPasswordPage';
|
||||
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
|
||||
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
|
||||
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
|
||||
export { usePermissions } from './lib/hooks/usePermissions';
|
||||
export type { PermissionSet } from './lib/hooks/usePermissions';
|
||||
export {
|
||||
useCurrentProfile,
|
||||
useGetMyProfileQuery,
|
||||
|
||||
@@ -94,6 +94,48 @@ export function LoginPage() {
|
||||
method: "POST",
|
||||
body: values,
|
||||
}).unwrap();
|
||||
|
||||
// MFA-enabled accounts get no tokens yet — an OTP has been sent, and
|
||||
// the session only exists once /auth/mfa-verify accepts it (US-IAM-006).
|
||||
if (data.mfaRequired) {
|
||||
setMfaEmail(values.email);
|
||||
notify.success('Enter the verification code we just sent you');
|
||||
return;
|
||||
}
|
||||
await completeSession(data);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { data?: { message?: string } })?.data?.message ??
|
||||
(err instanceof Error ? err.message : 'Something went wrong');
|
||||
setServerError(msg);
|
||||
notify.error(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyMfa = async () => {
|
||||
if (!mfaEmail || !mfaOtp.trim()) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await loginTrigger({
|
||||
url: '/auth/mfa-verify',
|
||||
method: 'POST',
|
||||
body: { email: mfaEmail, otp: mfaOtp.trim() },
|
||||
}).unwrap();
|
||||
// The second factor proves possession of the verified phone.
|
||||
await completeSession({ ...data, isPhoneNumberVerified: true });
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { data?: { message?: string } })?.data?.message ??
|
||||
'Verification failed';
|
||||
setServerError(msg === 'unable_to_log_in' ? 'Invalid or expired code' : msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeSession = async (data: LoginPayload) => {
|
||||
dispatch(loginSuccess(data));
|
||||
|
||||
const me = await meTrigger({
|
||||
@@ -162,6 +204,42 @@ export function LoginPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mfaEmail ? (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconDeviceMobile size={18} />}>
|
||||
This account requires a second factor. Enter the code we sent to
|
||||
your registered phone.
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Verification code"
|
||||
placeholder="6-digit code"
|
||||
size="md"
|
||||
value={mfaOtp}
|
||||
onChange={(e) => setMfaOtp(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && verifyMfa()}
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={!mfaOtp.trim()}
|
||||
onClick={verifyMfa}
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
>
|
||||
Verify and sign in
|
||||
</Button>
|
||||
<Anchor
|
||||
size="sm"
|
||||
ta="center"
|
||||
onClick={() => {
|
||||
setMfaEmail(null);
|
||||
setMfaOtp('');
|
||||
setServerError(null);
|
||||
}}
|
||||
>
|
||||
Back to sign in
|
||||
</Anchor>
|
||||
</Stack>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
|
||||
152
libs/auth/src/lib/pages/SetPasswordPage.tsx
Normal file
152
libs/auth/src/lib/pages/SetPasswordPage.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconLockCheck } from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
/** Mirrors the API's IsStrongPassword rule so failures are explained locally. */
|
||||
function passwordProblem(password: string): string | null {
|
||||
if (password.length < 8) return 'At least 8 characters';
|
||||
if (!/[a-z]/.test(password)) return 'At least one lowercase letter';
|
||||
if (!/[0-9]/.test(password)) return 'At least one number';
|
||||
if (!/[^A-Za-z0-9]/.test(password)) return 'At least one symbol';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the forgot-password flow (US-IAM-007).
|
||||
*
|
||||
* The reset message carries a link to this page with `userId` and `email` —
|
||||
* the set-password endpoint requires both, and the account holder only knows
|
||||
* one of them. Arriving without the link means the code alone cannot finish
|
||||
* the reset, and the page says so instead of failing cryptically.
|
||||
*/
|
||||
export function SetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const userId = params.get('userId') ?? '';
|
||||
const email = params.get('email') ?? '';
|
||||
|
||||
// The IAM package's link carries the code as `verificationCode`.
|
||||
const [code, setCode] = useState(
|
||||
params.get('verificationCode') ?? params.get('code') ?? '',
|
||||
);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [setPasswordTrigger, { isLoading }] = useApiMutation();
|
||||
|
||||
const linkMissing = !userId || !email;
|
||||
const problem = password ? passwordProblem(password) : null;
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (problem) return setError(`Password needs: ${problem.toLowerCase()}`);
|
||||
if (password !== confirm) return setError("Passwords don't match");
|
||||
try {
|
||||
await setPasswordTrigger({
|
||||
url: '/auth/set-password',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
userId,
|
||||
email,
|
||||
verificationCode: code,
|
||||
newPassword: password,
|
||||
confirmPassword: confirm,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('Password updated — sign in with your new password');
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
const message = (err as { data?: { message?: string } })?.data?.message;
|
||||
setError(
|
||||
typeof message === 'string' && message === 'verification_code_expired'
|
||||
? 'The code has expired. Request a new reset from the sign-in page.'
|
||||
: 'Could not set the password. Check the code and try again.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center mih="100vh" p="md">
|
||||
<Card withBorder radius="md" p="xl" w={420}>
|
||||
<Stack>
|
||||
<Stack gap={4} align="center">
|
||||
<IconLockCheck size={32} color="var(--mantine-color-blue-6)" />
|
||||
<Title order={3}>Set a new password</Title>
|
||||
{email && (
|
||||
<Text size="sm" c="dimmed">
|
||||
for {email}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{linkMissing ? (
|
||||
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
|
||||
Open this page from the reset link we sent you — the link carries
|
||||
the details needed to finish the reset. You can request one from
|
||||
the{' '}
|
||||
<Text
|
||||
component="span"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/forgot-password')}
|
||||
>
|
||||
forgot-password page
|
||||
</Text>
|
||||
.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
label="Verification code"
|
||||
placeholder="The code from the reset message"
|
||||
required
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.currentTarget.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
error={password && problem ? problem : undefined}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm password"
|
||||
required
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.currentTarget.value)}
|
||||
error={
|
||||
confirm && confirm !== password
|
||||
? "Passwords don't match"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Button
|
||||
fullWidth
|
||||
loading={isLoading}
|
||||
disabled={!code.trim() || !password || !confirm}
|
||||
onClick={submit}
|
||||
>
|
||||
Set password
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export interface LoginPayload {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
isPhoneNumberVerified: boolean;
|
||||
/** Set (with no tokens) when the account requires an OTP second factor. */
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface CurrentProfileAddress {
|
||||
@@ -74,6 +76,14 @@ export interface CurrentProfile {
|
||||
pob: string;
|
||||
maritalStatus: string;
|
||||
isComplete: boolean;
|
||||
/**
|
||||
* Registered-seafarer identity — written by the platform when a seafarer
|
||||
* registration is approved, null before that.
|
||||
*/
|
||||
seafarerNumber?: string | null;
|
||||
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
|
||||
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
|
||||
seafarerStatusReason?: string | null;
|
||||
user: AuthUser;
|
||||
address: CurrentProfileAddress;
|
||||
profession: CurrentProfileProfession;
|
||||
|
||||
Reference in New Issue
Block a user