mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 14:15:45 +00:00
Adding more functionalities for all the license types
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type {
|
||||
Result,
|
||||
ExamAppeal,
|
||||
ListResponse,
|
||||
CreateResultPayload,
|
||||
UpdateResultPayload,
|
||||
ModerateResultPayload,
|
||||
ResultDecisionPayload,
|
||||
DecideAppealPayload,
|
||||
} from '../types/result';
|
||||
|
||||
const resultApi = baseApi.injectEndpoints({
|
||||
@@ -32,6 +36,54 @@ const resultApi = baseApi.injectEndpoints({
|
||||
query: (id) => ({ url: `/results/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Quality control (US-EXAM-012/013/014) ---------------------------
|
||||
moderateResult: builder.mutation<Result, ModerateResultPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/${id}/moderate`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
approveResult: builder.mutation<Result, ResultDecisionPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/${id}/approve`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
returnResult: builder.mutation<Result, ResultDecisionPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/${id}/return`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
publishExamResults: builder.mutation<
|
||||
{ examId: string; published: number; skipped: number },
|
||||
string
|
||||
>({
|
||||
query: (examId) => ({
|
||||
url: `/results/exam/${examId}/publish`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
// --- Appeals (US-EXAM-016) -------------------------------------------
|
||||
getPendingAppeals: builder.query<ExamAppeal[], void>({
|
||||
query: () => '/results/appeals/pending',
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
decideAppeal: builder.mutation<ExamAppeal, DecideAppealPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/results/appeals/${id}/decide`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -43,4 +95,10 @@ export const {
|
||||
useCreateResultMutation,
|
||||
useUpdateResultMutation,
|
||||
useDeleteResultMutation,
|
||||
useModerateResultMutation,
|
||||
useApproveResultMutation,
|
||||
useReturnResultMutation,
|
||||
usePublishExamResultsMutation,
|
||||
useGetPendingAppealsQuery,
|
||||
useDecideAppealMutation,
|
||||
} = resultApi;
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useCreateResultMutation } from '../api/result-api';
|
||||
import { useGetExamRegistrationsQuery } from '../../exam/api/exam-api';
|
||||
import type { Exam } from '../../exam/types/exam';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
@@ -48,27 +49,50 @@ export function RecordResultModal({
|
||||
const [questionRemarks, setQuestionRemarks] = useState<Record<string, string>>({});
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
const { data: profilesRes } = useApiQuery<{ total: number; items: any[] }>({
|
||||
url: '/profiles',
|
||||
params: { q: 'w=type:=:SEAFARER' },
|
||||
// Only candidates who registered and actually sat the paper can be marked
|
||||
// (US-EXAM-009 feeding US-EXAM-011) — the API refuses anyone else, so the
|
||||
// picker is the session's own register rather than every seafarer profile.
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(exam.id, {
|
||||
skip: !opened,
|
||||
});
|
||||
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
|
||||
const [updateResult] = useUpdateResultMutation();
|
||||
|
||||
const seafarers = profilesRes?.items ?? [];
|
||||
const questions = exam.questions ?? [];
|
||||
|
||||
const seafarerOptions = seafarers.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: `${s.firstName} ${s.middleName ?? ''} ${s.lastName}`,
|
||||
}));
|
||||
const seafarerOptions = (registrations ?? [])
|
||||
.filter((registration) =>
|
||||
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
|
||||
)
|
||||
.map((registration) => ({
|
||||
value: registration.profileId,
|
||||
label: `${registration.admissionNumber} — ${[
|
||||
registration.profile?.firstName,
|
||||
registration.profile?.middleName,
|
||||
registration.profile?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}`,
|
||||
}));
|
||||
|
||||
const filteredOptions = seafarerSearch
|
||||
? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase()))
|
||||
: seafarerOptions;
|
||||
|
||||
const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0);
|
||||
const passed = totalScore >= exam.cuttingPoint;
|
||||
const maxScore = questions.reduce((sum, q) => sum + (q.points ?? 0), 0);
|
||||
// The cutting point is read per the exam's configured evaluation method —
|
||||
// an AVERAGE or PERCENTAGE exam must not be graded as a raw sum.
|
||||
const effectiveScore =
|
||||
exam.evaluationMethod === 'AVERAGE'
|
||||
? questions.length > 0
|
||||
? totalScore / questions.length
|
||||
: 0
|
||||
: exam.evaluationMethod === 'PERCENTAGE'
|
||||
? maxScore > 0
|
||||
? (totalScore / maxScore) * 100
|
||||
: 0
|
||||
: totalScore;
|
||||
const passed = effectiveScore >= exam.cuttingPoint;
|
||||
|
||||
const handleScoreChange = (questionId: string, value: number) => {
|
||||
setScores((prev) => ({ ...prev, [questionId]: value }));
|
||||
@@ -89,16 +113,16 @@ export function RecordResultModal({
|
||||
score: scores[q.id] ?? 0,
|
||||
remark: questionRemarks[q.id] ?? '',
|
||||
}));
|
||||
const created = await createResult({
|
||||
// The outcome is not sent: the API derives PASSED/FAILED from the
|
||||
// session's evaluation method and cutting point, and stamps the
|
||||
// examiner on the row (US-EXAM-011). The preview below shows what that
|
||||
// computation will produce.
|
||||
await createResult({
|
||||
seafarerId: selectedSeafarerId,
|
||||
examId: exam.id,
|
||||
resultBreakdowns: breakdowns,
|
||||
totalScore,
|
||||
remark: remark ? { en: remark, am: '' } : undefined,
|
||||
}).unwrap();
|
||||
if (passed && created.id) {
|
||||
await updateResult({ id: created.id, status: 'PASSED' }).unwrap();
|
||||
}
|
||||
notify.success(t('result.recordModal.saveSuccess'));
|
||||
setSelectedSeafarerId(null);
|
||||
setScores({});
|
||||
@@ -106,8 +130,17 @@ export function RecordResultModal({
|
||||
setRemark('');
|
||||
setSeafarerSearch('');
|
||||
onClose();
|
||||
} catch {
|
||||
notify.error(t('result.recordModal.saveError'));
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('result.recordModal.saveError'));
|
||||
notify.error(
|
||||
key === 'candidate_not_registered'
|
||||
? 'This candidate is not registered for the session.'
|
||||
: key.startsWith('candidate_not_present')
|
||||
? `No paper to mark — the register says ${key.split(':')[1] ?? ''}.`
|
||||
: key === 'result_already_recorded'
|
||||
? 'A result has already been recorded for this candidate.'
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconGavel, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetPendingAppealsQuery,
|
||||
useDecideAppealMutation,
|
||||
} from '../api/result-api';
|
||||
import type { ExamAppeal } from '../types/result';
|
||||
|
||||
/**
|
||||
* The appeals desk (US-EXAM-016).
|
||||
*
|
||||
* Upholding an appeal deliberately does not edit the mark here: it returns
|
||||
* the result to the examiner, so the correction travels the same moderation
|
||||
* and approval chain as the original.
|
||||
*/
|
||||
export function ExamAppealsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery();
|
||||
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
|
||||
|
||||
const [target, setTarget] = useState<ExamAppeal | null>(null);
|
||||
const [outcome, setOutcome] = useState<'UPHELD' | 'REJECTED'>('UPHELD');
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
const decide = async () => {
|
||||
if (!target) return;
|
||||
if (!remark.trim()) {
|
||||
notify.error(t('result.review.remarkRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await decideAppeal({ id: target.id, outcome, remark: remark.trim() }).unwrap();
|
||||
notify.success(t('result.appeals.decided'));
|
||||
setTarget(null);
|
||||
setRemark('');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.appeals.error')));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="red">
|
||||
{t('result.appeals.error')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>{t('result.appeals.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t('result.appeals.subtitle')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
{(appeals ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('result.appeals.none')}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('result.appeals.number')}</Table.Th>
|
||||
<Table.Th>{t('result.appeals.candidate')}</Table.Th>
|
||||
<Table.Th>{t('result.appeals.exam')}</Table.Th>
|
||||
<Table.Th>{t('result.appeals.reason')}</Table.Th>
|
||||
<Table.Th>{t('result.appeals.lodged')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(appeals ?? []).map((appeal) => (
|
||||
<Table.Tr key={appeal.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{appeal.appealNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">
|
||||
{appeal.profile
|
||||
? `${appeal.profile.firstName} ${appeal.profile.lastName}`
|
||||
: appeal.profileId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">
|
||||
{appeal.result?.exam?.title?.[locale] ?? '—'}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{appeal.result?.status} · {appeal.result?.totalScore}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" maw={300} lineClamp={3}>
|
||||
{appeal.reason}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => {
|
||||
setTarget(appeal);
|
||||
setOutcome('UPHELD');
|
||||
setRemark('');
|
||||
}}
|
||||
>
|
||||
{t('result.appeals.decide')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(target)}
|
||||
onClose={() => setTarget(null)}
|
||||
title={`${t('result.appeals.decide')} — ${target?.appealNumber ?? ''}`}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm">{target?.reason}</Text>
|
||||
<Select
|
||||
label={t('result.appeals.decide')}
|
||||
data={[
|
||||
{ value: 'UPHELD', label: t('result.appeals.uphold') },
|
||||
{ value: 'REJECTED', label: t('result.appeals.reject') },
|
||||
]}
|
||||
value={outcome}
|
||||
onChange={(value) =>
|
||||
setOutcome((value as 'UPHELD' | 'REJECTED') ?? 'UPHELD')
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
{outcome === 'UPHELD' && (
|
||||
<Alert color="yellow" icon={<IconInfoCircle size={15} />}>
|
||||
{t('result.appeals.upheldHint')}
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea
|
||||
label={t('result.appeals.remark')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setTarget(null)}>
|
||||
{t('result.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" loading={isDeciding} onClick={decide}>
|
||||
{t('result.appeals.decide')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExamAppealsPage;
|
||||
@@ -34,13 +34,24 @@ import {
|
||||
IconCircleX,
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetResultsQuery,
|
||||
useLazyGetResultQuery,
|
||||
useDeleteResultMutation,
|
||||
useUpdateResultMutation,
|
||||
useModerateResultMutation,
|
||||
useApproveResultMutation,
|
||||
useReturnResultMutation,
|
||||
usePublishExamResultsMutation,
|
||||
} from '../api/result-api';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
import { RecordResultModal } from '../components/RecordResultModal';
|
||||
import type { Result, ResultBreakdown } from '../types/result';
|
||||
import type { Result, ResultBreakdown, ResultReviewStatus } from '../types/result';
|
||||
import type { Exam } from '../../exam/types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -48,6 +59,16 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
FAILED: 'red',
|
||||
};
|
||||
|
||||
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
|
||||
const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
|
||||
MARKED: 'gray',
|
||||
MODERATED: 'yellow',
|
||||
APPROVED: 'blue',
|
||||
PUBLISHED: 'teal',
|
||||
};
|
||||
|
||||
type QcAction = 'moderate' | 'approve' | 'return';
|
||||
|
||||
function ResultStat({
|
||||
label,
|
||||
value,
|
||||
@@ -104,13 +125,16 @@ export function ResultPage() {
|
||||
|
||||
const [deleteResult] = useDeleteResultMutation();
|
||||
const [updateResult] = useUpdateResultMutation();
|
||||
const [moderateResult, { isLoading: isModerating }] = useModerateResultMutation();
|
||||
const [approveResult, { isLoading: isApproving }] = useApproveResultMutation();
|
||||
const [returnResult, { isLoading: isReturning }] = useReturnResultMutation();
|
||||
const [publishResults, { isLoading: isPublishing }] = usePublishExamResultsMutation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [examFilter, setExamFilter] = useState<string | null>(null);
|
||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [detailStatus, setDetailStatus] = useState<string>('PASSED');
|
||||
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
|
||||
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
|
||||
const [detailSaving, setDetailSaving] = useState(false);
|
||||
@@ -118,6 +142,10 @@ export function ResultPage() {
|
||||
const [pickerOpened, { open: openPicker, close: closePicker }] = useDisclosure(false);
|
||||
const [recordExam, setRecordExam] = useState<Exam | null>(null);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
|
||||
const [qcTarget, setQcTarget] = useState<Result | null>(null);
|
||||
const [qcAction, setQcAction] = useState<QcAction>('approve');
|
||||
const [qcRemark, setQcRemark] = useState('');
|
||||
const [qcAdjustment, setQcAdjustment] = useState(0);
|
||||
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
|
||||
|
||||
@@ -162,7 +190,6 @@ export function ResultPage() {
|
||||
|
||||
const viewDetail = useCallback((result: Result) => {
|
||||
fetchDetail(result.id);
|
||||
setDetailStatus(result.status);
|
||||
setDetailRemark({ en: result.remark?.en ?? '', am: result.remark?.am ?? '' });
|
||||
setDetailBreakdowns(result.resultBreakdowns.map((b) => ({ ...b })));
|
||||
openDetail();
|
||||
@@ -172,26 +199,82 @@ export function ResultPage() {
|
||||
if (!detailResult) return;
|
||||
setDetailSaving(true);
|
||||
try {
|
||||
// No `status`: re-marking recomputes the outcome on the server, and an
|
||||
// approved result is refused until it is returned to the examiner.
|
||||
await updateResult({
|
||||
id: detailResult.id,
|
||||
status: detailStatus as 'PASSED' | 'FAILED',
|
||||
remark: detailRemark.en || detailRemark.am ? detailRemark : undefined,
|
||||
resultBreakdowns: detailBreakdowns,
|
||||
}).unwrap();
|
||||
notify.success(t('result.updated'));
|
||||
closeDetail();
|
||||
} catch {
|
||||
notify.error(t('result.error'));
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('result.error'));
|
||||
notify.error(
|
||||
key === 'result_locked_after_approval'
|
||||
? t('result.review.lockedAfterApproval')
|
||||
: key,
|
||||
);
|
||||
} finally {
|
||||
setDetailSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openQc = (result: Result, action: QcAction) => {
|
||||
setQcTarget(result);
|
||||
setQcAction(action);
|
||||
setQcRemark('');
|
||||
setQcAdjustment(0);
|
||||
};
|
||||
|
||||
const runQc = async () => {
|
||||
if (!qcTarget) return;
|
||||
if (!qcRemark.trim()) {
|
||||
notify.error(t('result.review.remarkRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (qcAction === 'moderate') {
|
||||
await moderateResult({
|
||||
id: qcTarget.id,
|
||||
adjustment: qcAdjustment,
|
||||
remark: qcRemark.trim(),
|
||||
}).unwrap();
|
||||
notify.success(t('result.review.moderated'));
|
||||
} else if (qcAction === 'approve') {
|
||||
await approveResult({ id: qcTarget.id, remark: qcRemark.trim() }).unwrap();
|
||||
notify.success(t('result.review.approved'));
|
||||
} else {
|
||||
await returnResult({ id: qcTarget.id, remark: qcRemark.trim() }).unwrap();
|
||||
notify.success(t('result.review.returned'));
|
||||
}
|
||||
setQcTarget(null);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Publication is per session (US-EXAM-014) — a cohort sees its results
|
||||
* together — so it needs an exam in the filter to know which one.
|
||||
*/
|
||||
const handlePublish = async () => {
|
||||
if (!examFilter) {
|
||||
notify.error(t('result.review.publishNeedsExam'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const outcome = await publishResults(examFilter).unwrap();
|
||||
notify.success(t('result.review.publishedCount', outcome));
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('result.review.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailClose = () => {
|
||||
closeDetail();
|
||||
setDetailBreakdowns([]);
|
||||
setDetailRemark({ en: '', am: '' });
|
||||
setDetailStatus('PASSED');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -216,9 +299,22 @@ export function ResultPage() {
|
||||
<Title order={2}>{t('result.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
loading={isPublishing}
|
||||
disabled={!examFilter}
|
||||
leftSection={<IconSend size={15} />}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('result.review.publish')}
|
||||
</Button>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
|
||||
{t('result.record')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
|
||||
@@ -259,6 +355,7 @@ export function ResultPage() {
|
||||
<Table.Th>{t('result.columns.exam')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.status')}</Table.Th>
|
||||
<Table.Th>{t('result.review.column')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.date')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
@@ -289,9 +386,44 @@ export function ResultPage() {
|
||||
{t(`result.status.${r.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${r.reviewStatus}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => openQc(r, 'moderate')}
|
||||
>
|
||||
{t('result.review.moderate')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => openQc(r, 'approve')}
|
||||
>
|
||||
{t('result.review.approve')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => openQc(r, 'return')}
|
||||
>
|
||||
{t('result.review.return')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
@@ -315,7 +447,7 @@ export function ResultPage() {
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -373,16 +505,26 @@ export function ResultPage() {
|
||||
<Divider />
|
||||
|
||||
{/* Editable fields */}
|
||||
<Select
|
||||
label={t('result.detail.status')}
|
||||
data={[
|
||||
{ value: 'PASSED', label: t('result.status.PASSED') },
|
||||
{ value: 'FAILED', label: t('result.status.FAILED') },
|
||||
]}
|
||||
value={detailStatus}
|
||||
onChange={(v) => setDetailStatus(v ?? 'PASSED')}
|
||||
size="sm"
|
||||
/>
|
||||
{/* Read-only: the outcome follows the pass mark, not the officer. */}
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t('result.review.derivedStatus')}
|
||||
</Text>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}>
|
||||
{t(`result.status.${detailResult.status}`)}
|
||||
</Badge>
|
||||
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${detailResult.reviewStatus}`)}
|
||||
</Badge>
|
||||
{detailResult.preModerationScore != null && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t('result.review.originalScore')}: {detailResult.preModerationScore}
|
||||
{detailResult.moderationRemark ? ` — ${detailResult.moderationRemark}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<BilingualInput
|
||||
label={t('result.detail.remark')}
|
||||
@@ -467,6 +609,53 @@ export function ResultPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Moderation, approval and return — the quality-control chain */}
|
||||
<Modal
|
||||
opened={Boolean(qcTarget)}
|
||||
onClose={() => setQcTarget(null)}
|
||||
title={t(`result.review.${qcAction}`)}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm">
|
||||
{qcTarget?.seafarer
|
||||
? `${qcTarget.seafarer.firstName} ${qcTarget.seafarer.lastName}`
|
||||
: qcTarget?.seafarerId.slice(0, 8)}{' '}
|
||||
· {qcTarget ? getExamTitle(qcTarget.examId) : ''} · {qcTarget?.totalScore}
|
||||
</Text>
|
||||
{qcAction === 'moderate' && (
|
||||
<TextInput
|
||||
label={t('result.review.adjustment')}
|
||||
description={t('result.review.adjustmentHint')}
|
||||
type="number"
|
||||
value={qcAdjustment}
|
||||
onChange={(e) => setQcAdjustment(Number(e.currentTarget.value))}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label={t('result.review.remark')}
|
||||
value={qcRemark}
|
||||
onChange={(e) => setQcRemark(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setQcTarget(null)}>
|
||||
{t('result.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={isModerating || isApproving || isReturning}
|
||||
onClick={runQc}
|
||||
>
|
||||
{t(`result.review.${qcAction}`)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('result.deleteConfirmText')}</Text>
|
||||
<Group justify="flex-end">
|
||||
|
||||
@@ -5,6 +5,15 @@ export type { QuestionForm };
|
||||
|
||||
export type ExamResultStatus = 'PASSED' | 'FAILED';
|
||||
|
||||
/** How far a mark has travelled through quality control (US-EXAM-011 → 014). */
|
||||
export type ResultReviewStatus = 'MARKED' | 'MODERATED' | 'APPROVED' | 'PUBLISHED';
|
||||
|
||||
export type ExamAppealStatus =
|
||||
| 'SUBMITTED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'UPHELD'
|
||||
| 'REJECTED';
|
||||
|
||||
export interface ResultBreakdown {
|
||||
questionId: string;
|
||||
score: number;
|
||||
@@ -52,27 +61,70 @@ export interface Result {
|
||||
totalScore: number;
|
||||
remark: { en: string; am: string } | null;
|
||||
status: ExamResultStatus;
|
||||
reviewStatus: ResultReviewStatus;
|
||||
markedById: string | null;
|
||||
markedAt: string | null;
|
||||
preModerationScore: number | null;
|
||||
moderationAdjustment: number | null;
|
||||
moderationRemark: string | null;
|
||||
approvalRemark: string | null;
|
||||
approvedAt: string | null;
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** A candidate's challenge to a published result (US-EXAM-016). */
|
||||
export interface ExamAppeal {
|
||||
id: string;
|
||||
appealNumber: string;
|
||||
resultId: string;
|
||||
profileId: string;
|
||||
reason: string;
|
||||
status: ExamAppealStatus;
|
||||
decisionRemark: string | null;
|
||||
decidedAt: string | null;
|
||||
createdAt: string;
|
||||
result?: Result;
|
||||
profile?: Profile;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
total: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/** The total and the outcome are both computed server-side (US-EXAM-011). */
|
||||
export interface CreateResultPayload {
|
||||
seafarerId: string;
|
||||
examId: string;
|
||||
resultBreakdowns: ResultBreakdown[];
|
||||
totalScore?: number;
|
||||
remark?: { en: string; am: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* `status` is absent on purpose: PASSED/FAILED is derived on the server from
|
||||
* the session's cutting point and evaluation method (US-EXAM-011).
|
||||
*/
|
||||
export interface UpdateResultPayload {
|
||||
id: string;
|
||||
resultBreakdowns?: ResultBreakdown[];
|
||||
totalScore?: number;
|
||||
remark?: { en: string; am: string };
|
||||
status?: ExamResultStatus;
|
||||
}
|
||||
|
||||
export interface ModerateResultPayload {
|
||||
id: string;
|
||||
adjustment: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface ResultDecisionPayload {
|
||||
id: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface DecideAppealPayload {
|
||||
id: string;
|
||||
outcome: 'UPHELD' | 'REJECTED';
|
||||
remark: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user