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