Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-08 09:03:50 +03:00
66 changed files with 6665 additions and 3846 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -38,19 +38,22 @@ import {
IconUser,
IconCheck,
IconX,
IconDetails
} from "@tabler/icons-react";
import { notify, useErrorHandler, ModalFooter } from "@ema-platform/ui";
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetExamQuery,
useUpdateExamMutation,
useAssignQuestionsMutation,
} from "../api/exam-api";
import { useGetQuestionsQuery } from "../../question/api/question-api";
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
import { QuestionAssigner } from "../components/QuestionAssigner";
import { RecordResultModal } from "../../result/components/RecordResultModal";
import type { ExamStatus, QuestionBrief, actionTypes } from "../types/exam";
useSelectRandomQuestionsMutation,
} from '../api/exam-api';
import { useGetQuestionsQuery } from '../../question/api/question-api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import { QuestionAssigner } from '../components/QuestionAssigner';
import { RecordResultModal } from '../../result/components/RecordResultModal';
import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel';
import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
import type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
@@ -100,31 +103,27 @@ export function ExamDetailPage() {
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
const [randomCount, setRandomCount] = useState(5);
const [updateExam] = useUpdateExamMutation();
const [assignQuestions, { isLoading: isAssigning }] =
useAssignQuestionsMutation();
const {
data: exam,
isLoading,
isError,
} = useGetExamQuery(id ?? "", { skip: !id });
const [assignQuestions, { isLoading: isAssigning }] = useAssignQuestionsMutation();
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
const { data: qRes } = useGetQuestionsQuery();
const { data: certRes } = useGetCertificationsQuery();
const allQuestions = qRes?.items ?? [];
const certifications = certRes?.items ?? [];
const [whatAction, setWhatAction] = useState<actionTypes>();
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
// must not offer drafts or retired questions either.
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
.filter(
(q) =>
q.certificationId === exam.certificationId && q.form === exam.form,
q.certificationId === exam.certificationId &&
q.form === exam.form &&
q.status === 'APPROVED',
)
.map((q) => ({
id: q.id,
title: q.title,
form: q.form,
points: q.points,
}));
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allQuestions, exam?.certificationId, exam?.form]);
@@ -158,75 +157,40 @@ export function ExamDetailPage() {
openAssign();
};
const handleRandomSelect = () => {
const assignedIds = new Set(draftQuestions.map((q) => q.id));
const currentTotal = draftQuestions.reduce(
(s, q) => s + Number(q.points),
0,
);
const cuttingPoint = Number(exam.cuttingPoint);
const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id));
if (eligible.length === 0) {
notify.error("No eligible questions available for random selection");
return;
}
const maxPossible =
currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
if (maxPossible < cuttingPoint) {
/**
* The draw happens on the server (US-EXAM-005): it picks only approved
* items for this subject and writes the paper in one call, so the pool is
* never shipped to the browser and cannot be reshuffled until it flatters.
*/
const handleRandomSelect = async () => {
try {
const updated = await selectRandom({ examId: exam.id, count: randomCount }).unwrap();
setDraftQuestions(updated.questions ?? []);
notify.success(t('exam.randomSelected', { count: (updated.questions ?? []).length }));
closeAssign();
} catch (error) {
const key = extractErrorMessage(error, t('exam.randomError'));
notify.error(
`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`,
key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
: key,
);
return;
}
const shuffled = [...eligible].sort(() => Math.random() - 0.5);
const targetCount = Math.min(randomCount, shuffled.length);
const picked = shuffled.slice(0, targetCount);
let pickedTotal = picked.reduce((s, q) => s + Number(q.points), 0);
if (currentTotal + pickedTotal < cuttingPoint) {
const remaining = shuffled.slice(targetCount);
for (const q of remaining) {
if (currentTotal + pickedTotal >= cuttingPoint) break;
picked.push(q);
pickedTotal += q.points;
}
}
const msg =
picked.length > targetCount
? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)`
: `Randomly selected ${picked.length} questions`;
setDraftQuestions([...draftQuestions, ...picked]);
notify.info(msg);
};
const handleAssign = async () => {
try {
const questionIds = draftQuestions.map((q) => q.id);
if (whatAction === "add") {
await assignQuestions({
examId: exam.id,
questionIds,
remark: undefined,
}).unwrap();
notify.success("Questions assigned");
closeAssign();
} else if (whatAction === "remove") {
// await removeQuestions({
// examId: exam.id,
// questionIds,
// remark: undefined,
// });
// notify.success("Questions removed");
// closeAssign();
notify.warning("no action yet");
}
} catch (e) {
handleError(e);
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
notify.success('Questions assigned');
closeAssign();
} catch (error) {
const key = extractErrorMessage(error, 'Failed to assign questions');
notify.error(
key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable')
: key,
);
}
};
const handlePrint = async () => {
@@ -470,11 +434,11 @@ export function ExamDetailPage() {
)}
</Paper>
<RecordResultModal
exam={exam}
opened={recordOpened}
onClose={closeRecord}
/>
{/* Exam-day operations: who sat the paper, and what went wrong */}
<ExamCandidatesPanel examId={exam.id} />
<ExamIncidentsPanel examId={exam.id} />
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
{/* Question assignment modal */}
<Modal
@@ -505,24 +469,18 @@ export function ExamDetailPage() {
</>
) : (
<>
<Text fz="sm" c="dimmed">
{t("exam.assigner.randomHint", {
total: eligibleQuestions.length,
pts: exam.cuttingPoint,
})}
</Text>
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
<Group gap="sm">
<NumberInput
placeholder={t("exam.assigner.selectCount")}
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
max={eligibleQuestions.length}
size="xs"
style={{ width: 80 }}
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
{t("exam.randomSelect")}
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
{t('exam.randomSelect')}
</Button>
</Group>
<QuestionAssigner

View File

@@ -85,4 +85,83 @@ export interface AssignQuestionsPayload {
questionIds: string[];
remark?: LocalePair;
}
export type actionTypes = "add" | "remove";
export interface RandomQuestionsPayload {
examId: string;
count: number;
}
/** What the invigilator recorded on the day (US-EXAM-009). */
export type AttendanceStatus =
| 'REGISTERED'
| 'PRESENT'
| 'ABSENT'
| 'LATE'
| 'WITHDRAWN'
| 'DISQUALIFIED';
export interface ExamRegistration {
id: string;
examId: string;
profileId: string;
admissionNumber: string;
kind: 'NEW' | 'RETAKE';
attemptNumber: number;
attendanceStatus: AttendanceStatus;
attendanceRemark: string | null;
attendanceRecordedAt: string | null;
createdAt: string;
profile?: {
id: string;
firstName: string | null;
middleName: string | null;
lastName: string | null;
seafarerNumber: string | null;
};
}
export interface RecordAttendancePayload {
registrationId: string;
status: AttendanceStatus;
remark?: string;
}
export type ExamIncidentType =
| 'MISCONDUCT'
| 'TECHNICAL_FAILURE'
| 'MEDICAL'
| 'ADMINISTRATIVE'
| 'OTHER';
export type ExamIncidentStatus =
| 'OPEN'
| 'UNDER_REVIEW'
| 'RESOLVED'
| 'DISMISSED';
export interface ExamIncident {
id: string;
examId: string;
registrationId: string | null;
type: ExamIncidentType;
occurredAt: string;
description: string;
status: ExamIncidentStatus;
resolution: string | null;
resolvedAt: string | null;
registration?: ExamRegistration;
}
export interface CreateIncidentPayload {
examId: string;
type: ExamIncidentType;
registrationId?: string;
description: string;
occurredAt?: string;
}
export interface ResolveIncidentPayload {
incidentId: string;
outcome: 'RESOLVED' | 'DISMISSED';
resolution: string;
}

View File

@@ -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;

View File

@@ -12,6 +12,7 @@ import {
Modal,
NumberInput,
Paper,
SegmentedControl,
Skeleton,
Stack,
Table,
@@ -73,6 +74,30 @@ import { resolveActions, type ActionId, type ResolvedAction } from '../config/ac
type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
/**
* Standard site-visit checklist (US-LOG-016 / US-VES-007). The inspection
* entity has carried a checklist column since day one; this is the first UI
* that fills it in.
*/
const INSPECTION_CHECKLIST_ITEMS = [
{ key: 'office_premises', label: 'Office premises' },
{ key: 'storage_facilities', label: 'Warehouse / storage facilities' },
{ key: 'vehicles_equipment', label: 'Vehicles / equipment' },
{ key: 'safety_compliance', label: 'Safety & regulatory compliance' },
] as const;
function buildChecklist(
outcomes: Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>,
) {
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
key: item.key,
label: item.label,
// Untouched rows default to PASS — the segmented control shows exactly
// that, so what the officer saw is what gets recorded.
outcome: outcomes[item.key] ?? 'PASS',
}));
}
/**
* The officer's review workspace.
*
@@ -124,6 +149,9 @@ export function LicenseReviewPage() {
const [inspectionDate, setInspectionDate] = useState('');
const [resultOpen, setResultOpen] = useState(false);
const [findings, setFindings] = useState('');
const [checklist, setChecklist] = useState<
Record<string, 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'>
>({});
// Prefill from whatever is recorded, else the declared figure, so the officer
// confirms a number rather than retyping it. Runs before the early return
@@ -934,6 +962,36 @@ export function LicenseReviewPage() {
title={t('review.inspectionResult', 'Inspection result')}
>
<Stack>
{/* Structured per-area outcomes; persisted to the inspection's
checklist column, which was previously never populated. */}
<Stack gap={6}>
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
<Group key={item.key} justify="space-between" wrap="nowrap">
<Text size="sm">{item.label}</Text>
<SegmentedControl
size="xs"
value={checklist[item.key] ?? 'PASS'}
onChange={(value) =>
setChecklist((prev) => ({
...prev,
[item.key]: value as
| 'PASS'
| 'FAIL'
| 'NEEDS_CORRECTION',
}))
}
data={[
{ value: 'PASS', label: t('review.checkPass', 'Pass') },
{
value: 'NEEDS_CORRECTION',
label: t('review.checkFix', 'Fix'),
},
{ value: 'FAIL', label: t('review.checkFail', 'Fail') },
]}
/>
</Group>
))}
</Stack>
<Textarea
label={t('review.findings', 'Findings')}
withAsterisk
@@ -959,6 +1017,7 @@ export function LicenseReviewPage() {
applicationId: id,
result: 'PASSED',
findings,
checklist: buildChecklist(checklist),
}).unwrap();
setResultOpen(false);
}, t('review.done.inspectionPassed', 'Inspection passed'))
@@ -982,6 +1041,7 @@ export function LicenseReviewPage() {
applicationId: id,
result: 'FAILED',
findings,
checklist: buildChecklist(checklist),
}).unwrap();
setResultOpen(false);
}, t('review.done.inspectionFailed', 'Inspection failed'))

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -14,19 +14,39 @@ import {
Alert,
Select,
NumberInput,
Textarea,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import {
IconEdit,
IconTrash,
IconPlus,
IconInfoCircle,
IconSend,
IconGavel,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import {
useGetQuestionsQuery,
useCreateQuestionMutation,
useUpdateQuestionMutation,
useDeleteQuestionMutation,
useSubmitQuestionMutation,
useReviewQuestionMutation,
} from '../api/question-api';
import type { Question, QuestionForm } from '../types/question';
import type { Question, QuestionForm, QuestionStatus } from '../types/question';
/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */
const QC_COLOR: Record<QuestionStatus, string> = {
DRAFT: 'gray',
PENDING_APPROVAL: 'yellow',
APPROVED: 'teal',
REJECTED: 'red',
RETIRED: 'dark',
};
function QuestionForm({
editing,
@@ -107,6 +127,8 @@ export function QuestionPage() {
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
const [deleteQ] = useDeleteQuestionMutation();
const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation();
const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation();
const certifications = certRes?.items ?? [];
const questions = data?.items ?? [];
@@ -116,6 +138,9 @@ export function QuestionPage() {
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [certFilter, setCertFilter] = useState<string | null>(null);
const [reviewTarget, setReviewTarget] = useState<Question | null>(null);
const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED');
const [reviewRemark, setReviewRemark] = useState('');
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
@@ -141,8 +166,42 @@ export function QuestionPage() {
notify.success(t('question.created'));
}
resetForm();
} catch (e) {
handleError(e);
} catch {
notify.error(t('question.error'));
}
};
const handleSubmitForApproval = async (question: Question) => {
try {
await submitQ(question.id).unwrap();
notify.success(t('question.qc.submitted'));
} catch (error) {
notify.error(extractErrorMessage(error, t('question.qc.error')));
}
};
const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => {
setReviewTarget(question);
setReviewOutcome(outcome);
setReviewRemark('');
};
const handleReview = async () => {
if (!reviewTarget) return;
if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) {
notify.error(t('question.qc.remarkRequired'));
return;
}
try {
await reviewQ({
id: reviewTarget.id,
outcome: reviewOutcome,
remark: reviewRemark.trim() || undefined,
}).unwrap();
notify.success(t('question.qc.reviewed'));
setReviewTarget(null);
} catch (error) {
notify.error(extractErrorMessage(error, t('question.qc.error')));
}
};
@@ -239,19 +298,125 @@ export function QuestionPage() {
clearable
/>
</Group>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('question.title')}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('question.noQuestions')}
/>
</Card>
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('question.columns.title')}</Table.Th>
<Table.Th>{t('question.columns.certification')}</Table.Th>
<Table.Th>{t('question.columns.form')}</Table.Th>
<Table.Th>{t('question.columns.points')}</Table.Th>
<Table.Th>{t('question.qc.column')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={QC_COLOR[q.status] ?? 'gray'} title={q.reviewRemark ?? undefined}>
{t(`question.qc.${q.status}`)}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
<Button
size="compact-xs"
variant="light"
leftSection={<IconSend size={12} />}
loading={isSubmittingReview}
onClick={() => handleSubmitForApproval(q)}
>
{t('question.qc.submit')}
</Button>
)}
{q.status === 'PENDING_APPROVAL' && (
<>
<Button
size="compact-xs"
variant="light"
color="teal"
onClick={() => openReview(q, 'APPROVED')}
>
{t('question.qc.approve')}
</Button>
<Button
size="compact-xs"
variant="light"
color="red"
onClick={() => openReview(q, 'REJECTED')}
>
{t('question.qc.reject')}
</Button>
</>
)}
{q.status === 'APPROVED' && (
<Button
size="compact-xs"
variant="subtle"
color="dark"
leftSection={<IconGavel size={12} />}
onClick={() => openReview(q, 'RETIRED')}
>
{t('question.qc.retire')}
</Button>
)}
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<Modal
opened={Boolean(reviewTarget)}
onClose={() => setReviewTarget(null)}
title={t('question.qc.reviewTitle')}
size="md"
radius="lg"
>
<Stack gap="sm">
<Text fz="sm" fw={500}>{reviewTarget?.title?.[locale]}</Text>
<Text fz="xs" c="dimmed">{t('question.qc.onlyApprovedUsable')}</Text>
<Badge variant="light" color={reviewOutcome === 'APPROVED' ? 'teal' : reviewOutcome === 'REJECTED' ? 'red' : 'dark'} w="fit-content">
{t(`question.qc.${reviewOutcome}`)}
</Badge>
<Textarea
label={t('question.qc.remark')}
minRows={3}
autosize
value={reviewRemark}
onChange={(e) => setReviewRemark(e.currentTarget.value)}
required={reviewOutcome !== 'APPROVED'}
/>
<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>

View File

@@ -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[];

View File

@@ -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;

View File

@@ -17,9 +17,10 @@ import {
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
import { useApiQuery } from '@ema-platform/api';
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
import { notify } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { useCreateResultMutation } from '../api/result-api';
import { useGetExamRegistrationsQuery } from '../../exam/api/exam-api';
import type { Exam } from '../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) {
@@ -49,27 +50,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 }));
@@ -90,16 +114,16 @@ export function RecordResultModal({
score: scores[q.id] ?? 0,
remark: questionRemarks[q.id] ?? '',
}));
const created = await createResult({
// The outcome is not sent: the API derives PASSED/FAILED from the
// session's evaluation method and cutting point, and stamps the
// examiner on the row (US-EXAM-011). The preview below shows what that
// computation will produce.
await createResult({
seafarerId: selectedSeafarerId,
examId: exam.id,
resultBreakdowns: breakdowns,
totalScore,
remark: remark ? { en: remark, am: '' } : undefined,
}).unwrap();
if (passed && created.id) {
await updateResult({ id: created.id, status: 'PASSED' }).unwrap();
}
notify.success(t('result.recordModal.saveSuccess'));
setSelectedSeafarerId(null);
setScores({});
@@ -107,8 +131,17 @@ export function RecordResultModal({
setRemark('');
setSeafarerSearch('');
onClose();
} catch (e) {
handleError(e);
} catch (error) {
const key = extractErrorMessage(error, t('result.recordModal.saveError'));
notify.error(
key === 'candidate_not_registered'
? 'This candidate is not registered for the session.'
: key.startsWith('candidate_not_present')
? `No paper to mark — the register says ${key.split(':')[1] ?? ''}.`
: key === 'result_already_recorded'
? 'A result has already been recorded for this candidate.'
: key,
);
}
};

View File

@@ -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;

View File

@@ -35,13 +35,24 @@ import {
IconCircleX,
IconChartBar,
IconSearch,
IconSend,
} from '@tabler/icons-react';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetResultsQuery,
useLazyGetResultQuery,
useDeleteResultMutation,
useUpdateResultMutation,
useModerateResultMutation,
useApproveResultMutation,
useReturnResultMutation,
usePublishExamResultsMutation,
} from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
import { RecordResultModal } from '../components/RecordResultModal';
import type { Result, ResultBreakdown } from '../types/result';
import type { Result, ResultBreakdown, ResultReviewStatus } from '../types/result';
import type { Exam } from '../../exam/types/exam';
const STATUS_COLOR: Record<string, string> = {
@@ -49,6 +60,16 @@ const STATUS_COLOR: Record<string, string> = {
FAILED: 'red',
};
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
MARKED: 'gray',
MODERATED: 'yellow',
APPROVED: 'blue',
PUBLISHED: 'teal',
};
type QcAction = 'moderate' | 'approve' | 'return';
function ResultStat({
label,
value,
@@ -107,13 +128,16 @@ export function ResultPage() {
const [deleteResult] = useDeleteResultMutation();
const [updateResult] = useUpdateResultMutation();
const [moderateResult, { isLoading: isModerating }] = useModerateResultMutation();
const [approveResult, { isLoading: isApproving }] = useApproveResultMutation();
const [returnResult, { isLoading: isReturning }] = useReturnResultMutation();
const [publishResults, { isLoading: isPublishing }] = usePublishExamResultsMutation();
const [searchQuery, setSearchQuery] = useState('');
const [examFilter, setExamFilter] = useState<string | null>(null);
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [detailStatus, setDetailStatus] = useState<string>('PASSED');
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
const [detailSaving, setDetailSaving] = useState(false);
@@ -121,6 +145,10 @@ export function ResultPage() {
const [pickerOpened, { open: openPicker, close: closePicker }] = useDisclosure(false);
const [recordExam, setRecordExam] = useState<Exam | null>(null);
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
const [qcTarget, setQcTarget] = useState<Result | null>(null);
const [qcAction, setQcAction] = useState<QcAction>('approve');
const [qcRemark, setQcRemark] = useState('');
const [qcAdjustment, setQcAdjustment] = useState(0);
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
@@ -165,7 +193,6 @@ export function ResultPage() {
const viewDetail = useCallback((result: Result) => {
fetchDetail(result.id);
setDetailStatus(result.status);
setDetailRemark({ en: result.remark?.en ?? '', am: result.remark?.am ?? '' });
setDetailBreakdowns(result.resultBreakdowns.map((b) => ({ ...b })));
openDetail();
@@ -175,26 +202,82 @@ export function ResultPage() {
if (!detailResult) return;
setDetailSaving(true);
try {
// No `status`: re-marking recomputes the outcome on the server, and an
// approved result is refused until it is returned to the examiner.
await updateResult({
id: detailResult.id,
status: detailStatus as 'PASSED' | 'FAILED',
remark: detailRemark.en || detailRemark.am ? detailRemark : undefined,
resultBreakdowns: detailBreakdowns,
}).unwrap();
notify.success(t('result.updated'));
closeDetail();
} catch (e) {
handleError(e);
} catch (error) {
const key = extractErrorMessage(error, t('result.error'));
notify.error(
key === 'result_locked_after_approval'
? t('result.review.lockedAfterApproval')
: key,
);
} finally {
setDetailSaving(false);
}
};
const openQc = (result: Result, action: QcAction) => {
setQcTarget(result);
setQcAction(action);
setQcRemark('');
setQcAdjustment(0);
};
const runQc = async () => {
if (!qcTarget) return;
if (!qcRemark.trim()) {
notify.error(t('result.review.remarkRequired'));
return;
}
try {
if (qcAction === 'moderate') {
await moderateResult({
id: qcTarget.id,
adjustment: qcAdjustment,
remark: qcRemark.trim(),
}).unwrap();
notify.success(t('result.review.moderated'));
} else if (qcAction === 'approve') {
await approveResult({ id: qcTarget.id, remark: qcRemark.trim() }).unwrap();
notify.success(t('result.review.approved'));
} else {
await returnResult({ id: qcTarget.id, remark: qcRemark.trim() }).unwrap();
notify.success(t('result.review.returned'));
}
setQcTarget(null);
} catch (error) {
notify.error(extractErrorMessage(error, t('result.review.error')));
}
};
/**
* Publication is per session (US-EXAM-014) — a cohort sees its results
* together — so it needs an exam in the filter to know which one.
*/
const handlePublish = async () => {
if (!examFilter) {
notify.error(t('result.review.publishNeedsExam'));
return;
}
try {
const outcome = await publishResults(examFilter).unwrap();
notify.success(t('result.review.publishedCount', outcome));
} catch (error) {
notify.error(extractErrorMessage(error, t('result.review.error')));
}
};
const handleDetailClose = () => {
closeDetail();
setDetailBreakdowns([]);
setDetailRemark({ en: '', am: '' });
setDetailStatus('PASSED');
};
const handleDelete = async () => {
@@ -286,9 +369,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">
@@ -322,19 +418,113 @@ export function ResultPage() {
</Group>
</Group>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('result.title')}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
</Card>
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
<Table.Th>{t('result.columns.exam')}</Table.Th>
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
<Table.Th>{t('result.columns.status')}</Table.Th>
<Table.Th>{t('result.review.column')}</Table.Th>
<Table.Th>{t('result.columns.date')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td><Text fz="sm">{r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
/>
}
>
{t(`result.status.${r.status}`)}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
{t(`result.review.${r.reviewStatus}`)}
</Badge>
</Table.Td>
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
<Table.Td>
<Group gap="xs">
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<>
<Button
size="compact-xs"
variant="light"
color="yellow"
onClick={() => openQc(r, 'moderate')}
>
{t('result.review.moderate')}
</Button>
<Button
size="compact-xs"
variant="light"
color="blue"
onClick={() => openQc(r, 'approve')}
>
{t('result.review.approve')}
</Button>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<Button
size="compact-xs"
variant="subtle"
color="orange"
onClick={() => openQc(r, 'return')}
>
{t('result.review.return')}
</Button>
)}
<Button
size="xs"
variant="subtle"
leftSection={<IconEye size={13} />}
onClick={() => viewDetail(r)}
>
{t('result.action.viewEdit')}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => { setDeleteTarget(r); openDelete(); }}
>
{t('result.action.delete')}
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<Modal
opened={detailOpened}
@@ -385,16 +575,26 @@ export function ResultPage() {
<Divider />
{/* Editable fields */}
<Select
label={t('result.detail.status')}
data={[
{ value: 'PASSED', label: t('result.status.PASSED') },
{ value: 'FAILED', label: t('result.status.FAILED') },
]}
value={detailStatus}
onChange={(v) => setDetailStatus(v ?? 'PASSED')}
size="sm"
/>
{/* Read-only: the outcome follows the pass mark, not the officer. */}
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
{t('result.review.derivedStatus')}
</Text>
<Group gap="xs" mt={4}>
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}>
{t(`result.status.${detailResult.status}`)}
</Badge>
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
{t(`result.review.${detailResult.reviewStatus}`)}
</Badge>
{detailResult.preModerationScore != null && (
<Text fz="xs" c="dimmed">
{t('result.review.originalScore')}: {detailResult.preModerationScore}
{detailResult.moderationRemark ? `${detailResult.moderationRemark}` : ''}
</Text>
)}
</Group>
</div>
<BilingualInput
label={t('result.detail.remark')}
@@ -479,6 +679,53 @@ export function ResultPage() {
)}
</Modal>
{/* Moderation, approval and return — the quality-control chain */}
<Modal
opened={Boolean(qcTarget)}
onClose={() => setQcTarget(null)}
title={t(`result.review.${qcAction}`)}
size="md"
radius="lg"
>
<Stack gap="sm">
<Text fz="sm">
{qcTarget?.seafarer
? `${qcTarget.seafarer.firstName} ${qcTarget.seafarer.lastName}`
: qcTarget?.seafarerId.slice(0, 8)}{' '}
· {qcTarget ? getExamTitle(qcTarget.examId) : ''} · {qcTarget?.totalScore}
</Text>
{qcAction === 'moderate' && (
<TextInput
label={t('result.review.adjustment')}
description={t('result.review.adjustmentHint')}
type="number"
value={qcAdjustment}
onChange={(e) => setQcAdjustment(Number(e.currentTarget.value))}
size="sm"
/>
)}
<TextInput
label={t('result.review.remark')}
value={qcRemark}
onChange={(e) => setQcRemark(e.currentTarget.value)}
size="sm"
required
/>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setQcTarget(null)}>
{t('result.cancel')}
</Button>
<Button
size="sm"
loading={isModerating || isApproving || isReturning}
onClick={runQc}
>
{t(`result.review.${qcAction}`)}
</Button>
</Group>
</Stack>
</Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
<Text mb="md">{t('result.deleteConfirmText')}</Text>
<ModalFooter>

View File

@@ -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;
}

View File

@@ -1,16 +1,37 @@
import { useState } from 'react';
import {
Badge,
Button,
Card,
Container,
Drawer,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useApiQuery } from '@ema-platform/api';
import { AdvancedTable, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import {
IconAnchor,
IconSearch,
IconShieldCog,
IconStethoscope,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
extractErrorMessage,
useApiQuery,
useGetMedicalForProfileQuery,
useGetSeaServiceForProfileQuery,
useUpdateSeafarerStatusMutation,
} from '@ema-platform/api';
interface ProfileRow {
id: string;
@@ -20,19 +41,295 @@ interface ProfileRow {
gender?: string;
type?: string;
isComplete?: boolean;
seafarerNumber?: string | null;
seafarerStatus?: string | null;
seafarerDepartment?: string | null;
seafarerStatusReason?: string | null;
profession?: { name?: { en?: string } };
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
}
const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
/** The registered seafarer's records, read-only (verification is module 06). */
function SeafarerDetailDrawer({
profile,
onClose,
}: {
profile: ProfileRow | null;
onClose: () => void;
}) {
const profileId = profile?.id ?? '';
const { data: seaService, isLoading: loadingSea } =
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
const { data: medical, isLoading: loadingMedical } =
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
return (
<Drawer
opened={Boolean(profile)}
onClose={onClose}
position="right"
size="lg"
title={
profile
? [profile.firstName, profile.middleName, profile.lastName]
.filter(Boolean)
.join(' ')
: ''
}
>
{profile && (
<Stack>
<Group gap="xl">
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Seafarer number
</Text>
<Text fw={700} ff="monospace">
{profile.seafarerNumber ?? '—'}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Department
</Text>
<Text fw={600}>
{profile.seafarerDepartment
? DEPARTMENT_LABELS[profile.seafarerDepartment] ??
profile.seafarerDepartment
: '—'}
</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Status
</Text>
<Badge
color={
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
}
>
{profile.seafarerStatus ?? 'NOT REGISTERED'}
</Badge>
</div>
</Group>
{profile.seafarerStatusReason && (
<Text size="sm" c="dimmed">
Status reason: {profile.seafarerStatusReason}
</Text>
)}
<Tabs defaultValue="sea-service" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
Sea Service
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
Medical
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="sea-service" pt="sm">
{loadingSea ? (
<Loader size="sm" />
) : (seaService ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
No sea-service records.
</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Vessel</Table.Th>
<Table.Th>Rank</Table.Th>
<Table.Th>Period</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(seaService ?? []).map((record) => (
<Table.Tr key={record.id}>
<Table.Td>
{record.vesselName}
{record.imoNumber && (
<Text size="xs" c="dimmed">
IMO {record.imoNumber}
</Text>
)}
</Table.Td>
<Table.Td>{record.rank}</Table.Td>
<Table.Td>
{record.engagementDate} {record.dischargeDate}
</Table.Td>
<Table.Td>
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
{record.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Tabs.Panel>
<Tabs.Panel value="medical" pt="sm">
{loadingMedical ? (
<Loader size="sm" />
) : (medical ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
No medical certificates.
</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Issuer</Table.Th>
<Table.Th>Validity</Table.Th>
<Table.Th>Fitness</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(medical ?? []).map((certificate) => (
<Table.Tr key={certificate.id}>
<Table.Td>{certificate.issuerName}</Table.Td>
<Table.Td>
{certificate.issueDate} {certificate.expiryDate}
</Table.Td>
<Table.Td>{certificate.fitnessStatus}</Table.Td>
<Table.Td>
<Badge
size="sm"
color={RECORD_STATUS_COLORS[certificate.status]}
>
{certificate.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Tabs.Panel>
</Tabs>
</Stack>
)}
</Drawer>
);
}
/** US-SEA-013: suspend / reinstate / close, always with a reason. */
function StatusModal({
profile,
onClose,
onDone,
}: {
profile: ProfileRow | null;
onClose: () => void;
onDone: () => void;
}) {
const [status, setStatus] = useState<string | null>(null);
const [reason, setReason] = useState('');
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
const submit = async () => {
if (!profile || !status) return;
try {
await updateStatus({
profileId: profile.id,
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
reason,
}).unwrap();
notify.success('Seafarer status updated');
onClose();
onDone();
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not update the status'));
}
};
return (
<Modal
opened={Boolean(profile)}
onClose={onClose}
title="Change seafarer status"
centered
>
<Stack>
<Text size="sm" c="dimmed">
{profile?.seafarerNumber} currently {profile?.seafarerStatus}. The
reason is recorded and visible to the seafarer.
</Text>
<Select
label="New status"
required
data={[
{ value: 'SUSPENDED', label: 'Suspend' },
{ value: 'INACTIVE', label: 'Close' },
{ value: 'ACTIVE', label: 'Reinstate' },
].filter((o) => o.value !== profile?.seafarerStatus)}
value={status}
onChange={setStatus}
/>
<Textarea
label="Reason"
required
minRows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color={status === 'ACTIVE' ? 'green' : 'orange'}
disabled={!status || reason.trim().length < 3}
loading={isLoading}
onClick={submit}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
);
}
/**
* Registered seafarer profiles, read from the real profiles endpoint.
*
* This previously listed a hardcoded roster, so the registry showed seafarers
* who had never registered.
* Registration review itself happens in the licence queue (the
* SEAFARER_REGISTRATION application type); this page is the resulting
* register — numbers, departments, statuses, and each seafarer's records.
*/
export function SeafarerRegistryPage() {
const [search, setSearch] = useState('');
const { data, isFetching, refetch } = useApiQuery<{ total: number; items: ProfileRow[] }>({
const [detail, setDetail] = useState<ProfileRow | null>(null);
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
const { data, isLoading, refetch } = useApiQuery<{
total: number;
items: ProfileRow[];
}>({
url: '/profiles',
method: 'GET',
params: { q: 'i=profession,address&t=200' },
@@ -42,7 +339,13 @@ export function SeafarerRegistryPage() {
const items = (data?.items ?? []).filter((p) => {
if (!search.trim()) return true;
const term = search.toLowerCase();
return [p.firstName, p.middleName, p.lastName, p.address?.idNumber]
return [
p.firstName,
p.middleName,
p.lastName,
p.address?.idNumber,
p.seafarerNumber,
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term));
});
@@ -85,32 +388,118 @@ export function SeafarerRegistryPage() {
<div>
<Title order={3}>Seafarer registry</Title>
<Text size="sm" c="dimmed">
{data?.total ?? 0} registered profile{(data?.total ?? 0) === 1 ? '' : 's'}
{data?.total ?? 0} profile{(data?.total ?? 0) === 1 ? '' : 's'}
</Text>
</div>
<TextInput
placeholder="Name or ID number"
placeholder="Name, ID or seafarer number"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => { setSearch(e.currentTarget.value); setPageIndex(0); }}
w={260}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
<Card withBorder padding={0}>
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Seafarer registry"
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
/>
{isLoading ? (
<Center h={200}>
<Loader />
</Center>
) : items.length === 0 ? (
<Center h={160}>
<Text size="sm" c="dimmed">
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Seafarer </Table.Th>
<Table.Th>Department</Table.Th>
<Table.Th>ID number</Table.Th>
<Table.Th>Phone</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((p) => (
<Table.Tr
key={p.id}
style={{ cursor: 'pointer' }}
onClick={() => setDetail(p)}
>
<Table.Td>
<Text size="sm" fw={500}>
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" ff="monospace">
{p.seafarerNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{p.seafarerDepartment
? DEPARTMENT_LABELS[p.seafarerDepartment] ?? p.seafarerDepartment
: '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{p.address?.idNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{p.address?.primaryPhoneNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
{p.seafarerNumber ? (
<Badge
size="sm"
variant="light"
color={SEAFARER_STATUS_COLORS[p.seafarerStatus ?? ''] ?? 'gray'}
>
{p.seafarerStatus}
</Badge>
) : (
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
{p.isComplete ? 'Not registered' : 'Incomplete'}
</Badge>
)}
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
{p.seafarerNumber && (
<Tooltip label="Suspend / reinstate / close">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconShieldCog size={14} />}
onClick={() => setStatusTarget(p)}
>
Status
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
<StatusModal
profile={statusTarget}
onClose={() => setStatusTarget(null)}
onDone={refetch}
/>
</Container>
);
}

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -28,6 +28,7 @@ export const am: Translations = {
nav: {
groupLicensing: 'ፈቃድ አሰጣጥ',
allApplications: 'ሁሉም ማመልከቻዎች',
licenceRegister: 'የፈቃድ መዝገብ',
certificateDesigner: 'የምስክር ወረቀት ንድፍ',
byType: 'በዓይነት',
typeFreightForwarder: 'የጭነት አስተላላፊ',
@@ -51,7 +52,7 @@ export const am: Translations = {
details: 'ዝርዝር',
licenceReview: 'የፈቃድ ማመልከቻዎች',
vesselRegistrationHeadDashboard: 'የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ',
vesselRegistrationQueue: 'የመርከብ ዝገባ ወረፋ',
vesselRegistrationQueue: 'የመርከብ ዝገ',
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
vesselRegistrationReport: 'የመርከብ ምዝገባ ሪፖርት',
ownershipTransferQueue: 'የባለቤትነት ዝውውር ወረፋ',
@@ -62,6 +63,8 @@ export const am: Translations = {
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
mtoLicense: 'የMTO ፈቃድ',
waiver: 'ነፃ ፈቃድ',
preWaiverQueue: 'የቅድመ ነፃ ፈቃድ ወረፋ',
postWaiverQueue: 'የድህረ ነፃ ፈቃድ ወረፋ',
menu: 'ምናሌ',
dashboard: 'ዳሽቦርድ',
userManagement: 'የተጠቃሚ አስተዳደር',
@@ -82,6 +85,7 @@ export const am: Translations = {
questions: 'ጥያቄዎች',
exams: 'ፈተናዎች',
examResults: 'የፈተና ውጤቶች',
examAppeals: 'የፈተና ይግባኞች',
collapseSidebar: 'ሰብስብ',
expandSidebar: 'ዘርጋ',
},
@@ -290,6 +294,63 @@ export const am: Translations = {
MANUAL: 'በእጅ',
RANDOM: 'በዘፈቀደ',
},
candidates: {
section: 'ተፈታኞች',
admission: 'የመግቢያ ቁጥር',
name: 'ተፈታኝ',
attempt: 'ሙከራ',
attendance: 'ተገኝነት',
remark: 'አስተያየት',
record: 'መዝግብ',
recorded: 'ተገኝነት ተመዝግቧል',
none: 'ለዚህ ፈተና እስካሁን የተመዘገበ ተፈታኝ የለም።',
retake: 'ድጋሚ {{n}}',
firstSitting: 'የመጀመሪያ ሙከራ',
remarkRequired: 'ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።',
},
attendance: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',
ABSENT: 'አልተገኘም',
LATE: 'ዘግይቷል',
WITHDRAWN: 'ወጥቷል',
DISQUALIFIED: 'ታግዷል',
},
incidents: {
section: 'ክስተቶች',
add: 'ክስተት መዝግብ',
type: 'ዓይነት',
candidate: 'ተፈታኝ',
wholeRoom: 'ሙሉ ፈተና',
description: 'የተከሰተው ነገር',
occurred: 'የተከሰተበት',
status: 'ሁኔታ',
resolve: 'ፍታ',
resolution: 'ውሳኔ',
outcome: 'ውጤት',
none: 'ለዚህ ፈተና የተመዘገበ ክስተት የለም።',
filed: 'ክስተት ተመዝግቧል',
resolved: 'ክስተት ተዘግቷል',
error: 'ተግባሩ አልተሳካም',
},
incidentType: {
MISCONDUCT: 'ሥነ ምግባር ጥሰት',
TECHNICAL_FAILURE: 'ቴክኒካዊ ብልሽት',
MEDICAL: 'የጤና',
ADMINISTRATIVE: 'አስተዳደራዊ',
OTHER: 'ሌላ',
},
incidentStatus: {
OPEN: 'ክፍት',
UNDER_REVIEW: 'በግምገማ ላይ',
RESOLVED: 'ተፈትቷል',
DISMISSED: 'ውድቅ ተደርጓል',
},
randomHintServer:
'ጥያቄዎች ለዚህ ትምህርት ከጸደቁ ንጥሎች በሰርቨሩ ላይ ይመረጣሉ፤ ስለዚህ ማከማቻው ለአሳሹ አይጋለጥም።',
randomSelected: '{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል',
randomError: 'ጥያቄዎችን መምረጥ አልተቻለም',
notEnoughApproved: 'ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።',
},
country: {
@@ -556,6 +617,49 @@ export const am: Translations = {
filterByExam: 'በፈተና አጣራ',
allExams: 'ሁሉም ፈተናዎች',
},
review: {
column: 'ግምገማ',
MARKED: 'ተስተካክሏል',
MODERATED: 'ተመርምሯል',
APPROVED: 'ጸድቋል',
PUBLISHED: 'ወጥቷል',
moderate: 'አስተካክል',
approve: 'አጽድቅ',
return: 'ወደ ፈታኙ መልስ',
publish: 'የፈተናውን ውጤቶች አውጣ',
adjustment: 'በጠቅላላ ውጤት ላይ የሚደረግ ማስተካከያ',
adjustmentHint: 'እንደ 3 ወይም -2 ያለ ለውጥ። ዜሮ ውጤቱን እንዳለ ያጸድቃል።',
remark: 'ምክንያት',
remarkRequired: 'ምክንያት ያስፈልጋል።',
moderated: 'ውጤቶች ተስተካክለዋል',
approved: 'ውጤት ጸድቋል',
returned: 'ውጤት ወደ ፈታኙ ተመልሷል',
publishedCount: '{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።',
publishNeedsExam: 'ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።',
lockedAfterApproval:
'ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።',
originalScore: 'የፈታኙ ጠቅላላ',
derivedStatus: 'ውጤት (ከማለፊያ ነጥብ የተገኘ)',
error: 'ተግባሩ አልተሳካም',
},
appeals: {
title: 'የፈተና ይግባኞች',
subtitle: 'ተፈታኞች በወጡ ውጤቶች ላይ ያቀረቡት ቅሬታ',
number: 'የይግባኝ ቁጥር',
candidate: 'ተፈታኝ',
exam: 'ፈተና',
reason: 'ምክንያት',
lodged: 'የቀረበበት',
decide: 'ወስን',
uphold: 'ተቀበል',
reject: 'አትቀበል',
remark: 'ውሳኔና ምክንያት',
decided: 'ይግባኙ ተወስኗል',
none: 'ውሳኔ የሚጠብቅ ይግባኝ የለም።',
upheldHint:
'ይግባኙን መቀበል ውጤቱን ለድጋሚ እርማት ወደ ፈታኙ ይመልሰዋል፤ እስኪጸድቅና እንደገና እስኪወጣ ተፈታኙ አያየውም።',
error: 'ውሳኔውን መመዝገብ አልተቻለም',
},
},
question: {
@@ -605,6 +709,25 @@ export const am: Translations = {
active: 'ንቁ',
inactive: 'እንቅስቃሴ የሌለ',
},
qc: {
column: 'ማጽደቅ',
DRAFT: 'ረቂቅ',
PENDING_APPROVAL: 'ማጽደቅ በመጠባበቅ ላይ',
APPROVED: 'ጸድቋል',
REJECTED: 'ተቀባይነት አላገኘም',
RETIRED: 'ከአገልግሎት ወጥቷል',
submit: 'ለማጽደቅ ላክ',
approve: 'አጽድቅ',
reject: 'አትቀበል',
retire: 'ከአገልግሎት አውጣ',
remark: 'ምክንያት',
remarkRequired: 'ላለመቀበል ወይም ከአገልግሎት ለማውጣት ምክንያት ያስፈልጋል።',
submitted: 'ለማጽደቅ ተልኳል',
reviewed: 'ውሳኔ ተመዝግቧል',
reviewTitle: 'የጥያቄ ማከማቻ ንጥል ግምገማ',
onlyApprovedUsable: 'የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።',
error: 'ተግባሩ አልተሳካም',
},
},
configuration: {

View File

@@ -26,6 +26,7 @@ export const en = {
nav: {
groupLicensing: 'Licensing',
allApplications: 'All Applications',
licenceRegister: 'Licence Register',
certificateDesigner: 'Certificate Designer',
byType: 'By Type',
typeFreightForwarder: 'Freight Forwarder',
@@ -53,7 +54,7 @@ export const en = {
userManagement: 'User Management',
seamanBookQueue: 'Seaman Book Queue',
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
vesselRegistrationQueue: 'Vessel Registration Queue',
vesselRegistrationQueue: 'Vessel Register',
vesselFormBuilder: 'Vessel Form Builder',
vesselRegistrationReport: 'Vessel Registration Report',
ownershipTransferQueue: 'Ownership Transfer Queue',
@@ -64,6 +65,8 @@ export const en = {
jointInvestmentLicense: 'Joint Investment License',
mtoLicense: 'MTO License',
waiver: 'Waiver',
preWaiverQueue: 'Pre-Waiver Queue',
postWaiverQueue: 'Post-Waiver Queue',
cocQueue: 'CoC / CoP Queue',
endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
@@ -80,6 +83,7 @@ export const en = {
questions: 'Questions',
exams: 'Examinations',
examResults: 'Exam Results',
examAppeals: 'Exam Appeals',
collapseSidebar: 'Collapse',
expandSidebar: 'Expand sidebar',
},
@@ -288,6 +292,64 @@ export const en = {
MANUAL: 'Manual',
RANDOM: 'Random',
},
candidates: {
section: 'Candidates',
admission: 'Admission №',
name: 'Candidate',
attempt: 'Attempt',
attendance: 'Attendance',
remark: 'Remark',
record: 'Record',
recorded: 'Attendance recorded',
none: 'No candidates have registered for this session yet.',
retake: 'Retake {{n}}',
firstSitting: 'First sitting',
remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
},
attendance: {
REGISTERED: 'Not called',
PRESENT: 'Present',
ABSENT: 'Absent',
LATE: 'Late',
WITHDRAWN: 'Withdrawn',
DISQUALIFIED: 'Disqualified',
},
incidents: {
section: 'Incidents',
add: 'Record incident',
type: 'Type',
candidate: 'Candidate',
wholeRoom: 'Whole session',
description: 'What happened',
occurred: 'Occurred',
status: 'Status',
resolve: 'Resolve',
resolution: 'Ruling',
outcome: 'Outcome',
none: 'No incidents recorded for this session.',
filed: 'Incident recorded',
resolved: 'Incident closed',
error: 'Operation failed',
},
incidentType: {
MISCONDUCT: 'Misconduct',
TECHNICAL_FAILURE: 'Technical failure',
MEDICAL: 'Medical',
ADMINISTRATIVE: 'Administrative',
OTHER: 'Other',
},
incidentStatus: {
OPEN: 'Open',
UNDER_REVIEW: 'Under review',
RESOLVED: 'Resolved',
DISMISSED: 'Dismissed',
},
randomHintServer:
'Questions are drawn on the server from approved bank items for this subject, so the pool is never exposed to the browser.',
randomSelected: 'Drew {{count}} approved questions',
randomError: 'Could not draw questions',
notEnoughApproved:
'Not enough approved questions in the bank for this subject.',
},
country: {
@@ -555,6 +617,50 @@ export const en = {
filterByExam: 'Filter by exam',
allExams: 'All Exams',
},
review: {
column: 'Review',
MARKED: 'Marked',
MODERATED: 'Moderated',
APPROVED: 'Approved',
PUBLISHED: 'Published',
moderate: 'Moderate',
approve: 'Approve',
return: 'Return to examiner',
publish: 'Publish session results',
adjustment: 'Adjustment to the total',
adjustmentHint:
'A signed change, e.g. 3 or -2. Zero endorses the mark as it stands.',
remark: 'Reason',
remarkRequired: 'A reason is required.',
moderated: 'Marks moderated',
approved: 'Result approved',
returned: 'Result returned to the examiner',
publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.',
publishNeedsExam: 'Filter by an exam first to publish its results.',
lockedAfterApproval:
'This result is approved and can no longer be edited. Return it to the examiner first.',
originalScore: 'Examiner total',
derivedStatus: 'Outcome (derived from the pass mark)',
error: 'Operation failed',
},
appeals: {
title: 'Examination appeals',
subtitle: 'Candidate challenges to published results',
number: 'Appeal №',
candidate: 'Candidate',
exam: 'Examination',
reason: 'Grounds',
lodged: 'Lodged',
decide: 'Decide',
uphold: 'Uphold',
reject: 'Reject',
remark: 'Decision and reason',
decided: 'Appeal decided',
none: 'No appeals are awaiting a ruling.',
upheldHint:
'Upholding returns the result to the examiner for re-marking; the candidate stops seeing it until it is approved and published again.',
error: 'Could not record the decision',
},
},
question: {
@@ -604,6 +710,26 @@ export const en = {
active: 'Active',
inactive: 'Inactive',
},
qc: {
column: 'Approval',
DRAFT: 'Draft',
PENDING_APPROVAL: 'Awaiting approval',
APPROVED: 'Approved',
REJECTED: 'Rejected',
RETIRED: 'Retired',
submit: 'Submit for approval',
approve: 'Approve',
reject: 'Reject',
retire: 'Retire',
remark: 'Reason',
remarkRequired: 'A reason is required to reject or retire an item.',
submitted: 'Sent for approval',
reviewed: 'Decision recorded',
reviewTitle: 'Review question bank item',
onlyApprovedUsable:
'Only approved items can be placed on an examination paper.',
error: 'Operation failed',
},
},
configuration: {

View File

@@ -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 },
],
},
{

View File

@@ -6,6 +6,7 @@ import {
import {
LoginPage,
ForgotPasswordPage,
SetPasswordPage,
OTPVerificationPage,
} from '@ema-platform/auth';
import { AuthLayout } from '../layouts/AuthLayout';
@@ -18,8 +19,6 @@ import { ConfigurationPage } from '../features/configuration/pages/Configuration
import { LocationPage } from '../features/location/pages/LocationPage';
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
import { CoCQueuePage } from '../features/coc-queue/pages/CoCQueuePage';
import { CoCReviewPage } from '../features/coc-queue/pages/CoCReviewPage';
import { EndorsementQueuePage } from '../features/endorsement/pages/EndorsementQueuePage';
import { EndorsementReviewPage } from '../features/endorsement/pages/EndorsementReviewPage';
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
@@ -30,14 +29,17 @@ import { QuestionPage } from '../features/question/pages/QuestionPage';
import { ExamPage } from '../features/exam/pages/ExamPage';
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
import { ResultPage } from '../features/result/pages/ResultPage';
import { ExamAppealsPage } from '../features/result/pages/ExamAppealsPage';
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage';
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
@@ -47,6 +49,8 @@ const router = createBrowserRouter([
children: [
{ path: '/login', element: <LoginPage /> },
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
{ path: '/set-password', element: <SetPasswordPage /> },
{ path: '/reset-password', element: <SetPasswordPage /> },
{ path: '/otp-verify', element: <OTPVerificationPage /> },
],
},
@@ -68,8 +72,9 @@ const router = createBrowserRouter([
{ path: 'locations', element: <LocationPage /> },
{ path: 'analytics', element: <AnalyticsPage /> },
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
{ path: 'coc-queue', element: <CoCQueuePage /> },
{ path: 'coc-queue/:id', element: <CoCReviewPage /> },
// CoC/CoP review happens in the config-driven licence queue.
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'endorsement-queue', element: <EndorsementQueuePage /> },
{ path: 'endorsement-queue/:id', element: <EndorsementReviewPage /> },
{ path: 'medical-verification', element: <MedicalVerificationPage /> },
@@ -80,7 +85,8 @@ const router = createBrowserRouter([
{ path: 'exams', element: <ExamPage /> },
{ path: 'exams/:id', element: <ExamDetailPage /> },
{ path: 'exam-results', element: <ResultPage /> },
// { path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
{ path: 'exam-appeals', element: <ExamAppealsPage /> },
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
@@ -89,6 +95,7 @@ const router = createBrowserRouter([
// Config-driven review workspace, shared by every licence type.
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
{ path: 'licence-review', element: <LicenseQueuePage /> },
{ path: 'licence-register', element: <LicenseRegisterPage /> },
// Deep link into the grid with the type facet pinned, so "Freight
// Forwarder" in the nav is a filtered view rather than a page.
{ path: 'licence-review/type/:typeCode', element: <LicenseQueuePage /> },
@@ -103,8 +110,10 @@ const router = createBrowserRouter([
{ path: 'joint-investment-license/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'mto-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'mto-license/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'waiver', element: <WaiverQueuePage /> },
{ path: 'waiver/:id', element: <WaiverReviewPage /> },
// Waivers are seeded licence types, so their queue is the licence
// queue filtered by type and their review is the licence review.
{ path: 'waiver', element: <Navigate to="/licence-review/type/PRE_WAIVER" replace /> },
{ path: 'waiver/:id', element: <Navigate to="/licence-review" replace /> },
],
},
],