mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 00:45:42 +00:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: "አልተጠራም",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user