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;