mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Adding more functionalities for all the license types
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>
|
||||
);
|
||||
}
|
||||
@@ -40,11 +40,19 @@ import {
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation } from '../api/exam-api';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamQuery,
|
||||
useUpdateExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
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> = {
|
||||
@@ -78,6 +86,7 @@ export function ExamDetailPage() {
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
const [assignQuestions, { isLoading: isAssigning }] = useAssignQuestionsMutation();
|
||||
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
|
||||
|
||||
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
|
||||
const { data: qRes } = useGetQuestionsQuery();
|
||||
@@ -85,10 +94,17 @@ export function ExamDetailPage() {
|
||||
const allQuestions = qRes?.items ?? [];
|
||||
const certifications = certRes?.items ?? [];
|
||||
|
||||
// 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)
|
||||
.filter(
|
||||
(q) =>
|
||||
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 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allQuestions, exam?.certificationId, exam?.form]);
|
||||
@@ -109,43 +125,25 @@ 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;
|
||||
/**
|
||||
* 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(
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key,
|
||||
);
|
||||
}
|
||||
|
||||
const maxPossible = currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
|
||||
if (maxPossible < cuttingPoint) {
|
||||
notify.error(`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`);
|
||||
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 () => {
|
||||
@@ -154,8 +152,13 @@ export function ExamDetailPage() {
|
||||
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
|
||||
notify.success('Questions assigned');
|
||||
closeAssign();
|
||||
} catch {
|
||||
notify.error('Failed to assign questions');
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Failed to assign questions');
|
||||
notify.error(
|
||||
key.startsWith('question_not_approved')
|
||||
? t('question.qc.onlyApprovedUsable')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -311,6 +314,10 @@ export function ExamDetailPage() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* 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 */}
|
||||
@@ -331,20 +338,17 @@ 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}>
|
||||
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
|
||||
{t('exam.randomSelect')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -79,3 +79,83 @@ export interface AssignQuestionsPayload {
|
||||
questionIds: string[];
|
||||
remark?: LocalePair;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -72,6 +73,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.
|
||||
*
|
||||
@@ -123,6 +148,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
|
||||
@@ -939,6 +967,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
|
||||
@@ -964,6 +1022,7 @@ export function LicenseReviewPage() {
|
||||
applicationId: id,
|
||||
result: 'PASSED',
|
||||
findings,
|
||||
checklist: buildChecklist(checklist),
|
||||
}).unwrap();
|
||||
setResultOpen(false);
|
||||
}, t('review.done.inspectionPassed', 'Inspection passed'))
|
||||
@@ -987,6 +1046,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;
|
||||
|
||||
@@ -16,19 +16,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 {
|
||||
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] }));
|
||||
|
||||
@@ -145,6 +170,40 @@ export function QuestionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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')));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
@@ -193,7 +252,7 @@ export function QuestionPage() {
|
||||
<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.columns.status')}</Table.Th>
|
||||
<Table.Th>{t('question.qc.column')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@@ -205,10 +264,54 @@ export function QuestionPage() {
|
||||
<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={q.isActive ? 'teal' : 'gray'}>{q.isActive ? t('question.status.active') : t('question.status.inactive')}</Badge>
|
||||
<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>
|
||||
@@ -230,6 +333,38 @@ export function QuestionPage() {
|
||||
</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'}
|
||||
/>
|
||||
<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>
|
||||
<Group justify="flex-end">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||
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 }) {
|
||||
@@ -48,27 +49,50 @@ 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
|
||||
? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase()))
|
||||
: 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 }));
|
||||
@@ -89,16 +113,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({});
|
||||
@@ -106,8 +130,17 @@ export function RecordResultModal({
|
||||
setRemark('');
|
||||
setSeafarerSearch('');
|
||||
onClose();
|
||||
} catch {
|
||||
notify.error(t('result.recordModal.saveError'));
|
||||
} 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;
|
||||
@@ -34,13 +34,24 @@ import {
|
||||
IconCircleX,
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput } 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> = {
|
||||
@@ -48,6 +59,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,
|
||||
@@ -104,13 +125,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);
|
||||
@@ -118,6 +142,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})` }));
|
||||
|
||||
@@ -162,7 +190,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();
|
||||
@@ -172,26 +199,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 {
|
||||
notify.error(t('result.error'));
|
||||
} 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 () => {
|
||||
@@ -216,9 +299,22 @@ export function ResultPage() {
|
||||
<Title order={2}>{t('result.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
<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">
|
||||
@@ -259,6 +355,7 @@ export function ResultPage() {
|
||||
<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>
|
||||
@@ -289,9 +386,44 @@ export function ResultPage() {
|
||||
{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"
|
||||
@@ -315,7 +447,7 @@ export function ResultPage() {
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -373,16 +505,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')}
|
||||
@@ -467,6 +609,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>
|
||||
<Group justify="flex-end">
|
||||
|
||||
@@ -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,18 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
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 {
|
||||
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;
|
||||
@@ -22,19 +42,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, isLoading } = 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' },
|
||||
@@ -43,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));
|
||||
});
|
||||
@@ -54,15 +356,15 @@ 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)}
|
||||
w={260}
|
||||
w={280}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -82,22 +384,37 @@ export function SeafarerRegistryPage() {
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Profession</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}>
|
||||
<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">{p.profession?.name?.en ?? '—'}</Text>
|
||||
<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">
|
||||
@@ -110,9 +427,33 @@ export function SeafarerRegistryPage() {
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
|
||||
{p.isComplete ? 'Complete' : 'Incomplete'}
|
||||
</Badge>
|
||||
{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>
|
||||
))}
|
||||
@@ -120,6 +461,13 @@ export function SeafarerRegistryPage() {
|
||||
</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;
|
||||
@@ -17,6 +17,7 @@ export const am: Translations = {
|
||||
nav: {
|
||||
groupLicensing: 'ፈቃድ አሰጣጥ',
|
||||
allApplications: 'ሁሉም ማመልከቻዎች',
|
||||
licenceRegister: 'የፈቃድ መዝገብ',
|
||||
certificateDesigner: 'የምስክር ወረቀት ንድፍ',
|
||||
byType: 'በዓይነት',
|
||||
typeFreightForwarder: 'የጭነት አስተላላፊ',
|
||||
@@ -40,7 +41,7 @@ export const am: Translations = {
|
||||
details: 'ዝርዝር',
|
||||
licenceReview: 'የፈቃድ ማመልከቻዎች',
|
||||
vesselRegistrationHeadDashboard: 'የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ',
|
||||
vesselRegistrationQueue: 'የመርከብ ምዝገባ ወረፋ',
|
||||
vesselRegistrationQueue: 'የመርከብ መዝገብ',
|
||||
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
|
||||
vesselRegistrationReport: 'የመርከብ ምዝገባ ሪፖርት',
|
||||
ownershipTransferQueue: 'የባለቤትነት ዝውውር ወረፋ',
|
||||
@@ -51,6 +52,8 @@ export const am: Translations = {
|
||||
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
|
||||
mtoLicense: 'የMTO ፈቃድ',
|
||||
waiver: 'ነፃ ፈቃድ',
|
||||
preWaiverQueue: 'የቅድመ ነፃ ፈቃድ ወረፋ',
|
||||
postWaiverQueue: 'የድህረ ነፃ ፈቃድ ወረፋ',
|
||||
menu: 'ምናሌ',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
userManagement: 'የተጠቃሚ አስተዳደር',
|
||||
@@ -68,6 +71,7 @@ export const am: Translations = {
|
||||
questions: 'ጥያቄዎች',
|
||||
exams: 'ፈተናዎች',
|
||||
examResults: 'የፈተና ውጤቶች',
|
||||
examAppeals: 'የፈተና ይግባኞች',
|
||||
collapseSidebar: 'ሰብስብ',
|
||||
expandSidebar: 'ዘርጋ',
|
||||
},
|
||||
@@ -270,6 +274,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: 'ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።',
|
||||
},
|
||||
|
||||
location: {
|
||||
@@ -531,6 +592,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: {
|
||||
@@ -580,6 +684,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: {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const en = {
|
||||
nav: {
|
||||
groupLicensing: 'Licensing',
|
||||
allApplications: 'All Applications',
|
||||
licenceRegister: 'Licence Register',
|
||||
certificateDesigner: 'Certificate Designer',
|
||||
byType: 'By Type',
|
||||
typeFreightForwarder: 'Freight Forwarder',
|
||||
@@ -42,7 +43,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',
|
||||
@@ -53,6 +54,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',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
@@ -66,6 +69,7 @@ export const en = {
|
||||
questions: 'Questions',
|
||||
exams: 'Examinations',
|
||||
examResults: 'Exam Results',
|
||||
examAppeals: 'Exam Appeals',
|
||||
collapseSidebar: 'Collapse',
|
||||
expandSidebar: 'Expand sidebar',
|
||||
},
|
||||
@@ -268,6 +272,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.',
|
||||
},
|
||||
|
||||
location: {
|
||||
@@ -530,6 +592,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: {
|
||||
@@ -579,6 +685,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,6 +29,7 @@ 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';
|
||||
@@ -38,9 +38,8 @@ import { VesselOwnershipTransferQueuePage } from '../features/vessel-registratio
|
||||
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';
|
||||
|
||||
@@ -50,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 /> },
|
||||
],
|
||||
},
|
||||
@@ -71,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 /> },
|
||||
@@ -83,6 +85,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'exams', element: <ExamPage /> },
|
||||
{ path: 'exams/:id', element: <ExamDetailPage /> },
|
||||
{ path: 'exam-results', element: <ResultPage /> },
|
||||
{ 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 /> },
|
||||
@@ -92,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 /> },
|
||||
@@ -106,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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user