feat(exam): regrade action + stop inviting a re-take on a finished exam

Two fixes surfaced by actual candidate-flow testing:

- Portal 'My registrations' always showed 'Take exam' regardless of whether
  the candidate had already finished — clicking it just hit
  attempt_already_submitted server-side. The registrations/mine response
  now carries the attempt's status; the row shows a Completed/Time expired
  badge once finished, or 'Resume exam' while still in progress, and only
  invites a fresh start when there's genuinely nothing yet.
- Backoffice candidates panel: a Regrade button next to a SUBMITTED/EXPIRED
  sitting, calling the new POST /exam-attempts/:id/regrade — the officer's
  path to the same staff-triggered regrade, no SQL needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mihretue
2026-08-17 12:00:45 +00:00
parent e97e12d979
commit 83d309248d
8 changed files with 116 additions and 19 deletions

View File

@@ -11,6 +11,7 @@ import type {
ExamIncident, ExamIncident,
CreateIncidentPayload, CreateIncidentPayload,
ResolveIncidentPayload, ResolveIncidentPayload,
RegradeOutcome,
} from '../types/exam'; } from '../types/exam';
const examApi = baseApi.injectEndpoints({ const examApi = baseApi.injectEndpoints({
@@ -92,6 +93,14 @@ const examApi = baseApi.injectEndpoints({
}), }),
invalidatesTags: ['Api'], invalidatesTags: ['Api'],
}), }),
/** Staff-triggered re-run of auto-grading for one finalized attempt. */
regradeAttempt: builder.mutation<RegradeOutcome, string>({
query: (attemptId) => ({
url: `/exam-attempts/${attemptId}/regrade`,
method: 'POST',
}),
invalidatesTags: ['Api'],
}),
}), }),
overrideExisting: false, overrideExisting: false,
}); });
@@ -109,4 +118,5 @@ export const {
useGetExamIncidentsQuery, useGetExamIncidentsQuery,
useRecordIncidentMutation, useRecordIncidentMutation,
useResolveIncidentMutation, useResolveIncidentMutation,
useRegradeAttemptMutation,
} = examApi; } = examApi;

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Text } from '@mantine/core'; import { Badge, Button, Text } from '@mantine/core';
import { IconUserCheck } from '@tabler/icons-react'; import { IconRefresh, IconUserCheck } from '@tabler/icons-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) =>
export function examCandidateColumns( export function examCandidateColumns(
t: TFunction, t: TFunction,
handlers: { onRecord: (registration: ExamRegistration) => void }, handlers: {
onRecord: (registration: ExamRegistration) => void;
onRegrade: (registration: ExamRegistration) => void;
regrading?: string | null;
},
): AdvancedColumn<ExamRegistration>[] { ): AdvancedColumn<ExamRegistration>[] {
return [ return [
{ {
@@ -78,21 +82,45 @@ export function examCandidateColumns(
header: '', header: '',
label: t('exam.candidates.record'), label: t('exam.candidates.record'),
align: 'right', align: 'right',
cell: ({ row }) => ( cell: ({ row }) => {
<RequirePermission const attemptStatus = row.original.attempt?.status;
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]} const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
hideOnly return (
> <>
<Button <RequirePermission
size="compact-xs" anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
variant="light" hideOnly
leftSection={<IconUserCheck size={12} />} >
onClick={() => handlers.onRecord(row.original)} <Button
> size="compact-xs"
{t('exam.candidates.record')} variant="light"
</Button> leftSection={<IconUserCheck size={12} />}
</RequirePermission> onClick={() => handlers.onRecord(row.original)}
), mr={canRegrade ? 6 : 0}
>
{t('exam.candidates.record')}
</Button>
</RequirePermission>
{canRegrade && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_RESULT]}
hideOnly
>
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconRefresh size={12} />}
loading={handlers.regrading === row.original.attempt?.id}
onClick={() => handlers.onRegrade(row.original)}
>
{t('exam.candidates.regrade')}
</Button>
</RequirePermission>
)}
</>
);
},
}, },
]; ];
} }

View File

@@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api';
import { import {
useGetExamRegistrationsQuery, useGetExamRegistrationsQuery,
useRecordAttendanceMutation, useRecordAttendanceMutation,
useRegradeAttemptMutation,
} from '../../api/exam-api'; } from '../../api/exam-api';
import type { AttendanceStatus, ExamRegistration } from '../../types/exam'; import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
import { candidateName, examCandidateColumns } from './columns'; import { candidateName, examCandidateColumns } from './columns';
@@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId); const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId);
const [recordAttendance, { isLoading }] = useRecordAttendanceMutation(); const [recordAttendance, { isLoading }] = useRecordAttendanceMutation();
const [regradeAttempt] = useRegradeAttemptMutation();
const [regrading, setRegrading] = useState<string | null>(null);
const [target, setTarget] = useState<ExamRegistration | null>(null); const [target, setTarget] = useState<ExamRegistration | null>(null);
const [status, setStatus] = useState<AttendanceStatus>('PRESENT'); const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
const [remark, setRemark] = useState(''); const [remark, setRemark] = useState('');
const table = useServerTable(); const table = useServerTable();
const regrade = async (registration: ExamRegistration) => {
const attemptId = registration.attempt?.id;
if (!attemptId) return;
setRegrading(attemptId);
try {
const outcome = await regradeAttempt(attemptId).unwrap();
if (outcome.graded) {
notify.success(t('exam.candidates.regraded'));
} else {
notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason }));
}
refetch();
} catch (error) {
notify.error(extractErrorMessage(error, t('exam.candidates.regradeError')));
} finally {
setRegrading(null);
}
};
const startRecording = (registration: ExamRegistration) => { const startRecording = (registration: ExamRegistration) => {
setTarget(registration); setTarget(registration);
setStatus( setStatus(
@@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
) : ( ) : (
<AdvancedTable <AdvancedTable
tableName={t('exam.candidates.section')} tableName={t('exam.candidates.section')}
columns={examCandidateColumns(t, { onRecord: startRecording })} columns={examCandidateColumns(t, {
onRecord: startRecording,
onRegrade: regrade,
regrading,
})}
data={paged.rows} data={paged.rows}
itemCount={paged.itemCount} itemCount={paged.itemCount}
pageIndex={paged.pageIndex} pageIndex={paged.pageIndex}

View File

@@ -126,8 +126,14 @@ export interface ExamRegistration {
lastName: string | null; lastName: string | null;
seafarerNumber: string | null; seafarerNumber: string | null;
}; };
/** The candidate's online sitting, when one has been started. */
attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
} }
export type RegradeOutcome =
| { graded: true; resultId: string }
| { graded: false; reason: string };
export interface RecordAttendancePayload { export interface RecordAttendancePayload {
registrationId: string; registrationId: string;
status: AttendanceStatus; status: AttendanceStatus;

View File

@@ -325,6 +325,10 @@ export const am: Translations = {
retake: "ድጋሚ {{n}}", retake: "ድጋሚ {{n}}",
firstSitting: "የመጀመሪያ ሙከራ", firstSitting: "የመጀመሪያ ሙከራ",
remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።", remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።",
regrade: "እንደገና ደረጃ ስጥ",
regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።",
regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።",
regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።",
}, },
attendance: { attendance: {
REGISTERED: "አልተጠራም", REGISTERED: "አልተጠራም",

View File

@@ -322,6 +322,10 @@ export const en = {
retake: 'Retake {{n}}', retake: 'Retake {{n}}',
firstSitting: 'First sitting', firstSitting: 'First sitting',
remarkRequired: 'A reason is required for a withdrawal or a disqualification.', remarkRequired: 'A reason is required for a withdrawal or a disqualification.',
regrade: 'Regrade',
regraded: 'Result created from the graded attempt.',
regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.',
regradeError: 'Could not regrade this attempt.',
}, },
attendance: { attendance: {
REGISTERED: 'Not called', REGISTERED: 'Not called',

View File

@@ -94,6 +94,23 @@ export function registrationColumns(deps: {
header: 'Exam', header: 'Exam',
cell: ({ row }) => { cell: ({ row }) => {
const exam = row.original.exam; const exam = row.original.exam;
const attemptStatus = row.original.attempt?.status;
// Already finished — no restart, no more room for "Take exam" to
// invite a click that the backend would just refuse.
if (attemptStatus === 'SUBMITTED') {
return (
<Badge size="sm" variant="light" color="teal">
Completed
</Badge>
);
}
if (attemptStatus === 'EXPIRED') {
return (
<Badge size="sm" variant="light" color="red">
Time expired
</Badge>
);
}
const eligible = const eligible =
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus); exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null; if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
@@ -104,7 +121,7 @@ export function registrationColumns(deps: {
leftSection={<IconPlayerPlay size={13} />} leftSection={<IconPlayerPlay size={13} />}
onClick={() => deps.onStartExam(row.original)} onClick={() => deps.onStartExam(row.original)}
> >
Take exam {attemptStatus === 'IN_PROGRESS' ? 'Resume exam' : 'Take exam'}
</Button> </Button>
); );
}, },

View File

@@ -54,6 +54,8 @@ export interface MyRegistration {
attemptNumber: number; attemptNumber: number;
attendanceStatus: AttendanceStatus; attendanceStatus: AttendanceStatus;
exam?: OpenExam; exam?: OpenExam;
/** The candidate's online sitting, when one has been started. */
attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null;
} }
export interface MyResult { export interface MyResult {