diff --git a/apps/backoffice/src/app/features/exam/api/exam-api.ts b/apps/backoffice/src/app/features/exam/api/exam-api.ts index 78f3c74ea..d313a38ac 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -11,6 +11,7 @@ import type { ExamIncident, CreateIncidentPayload, ResolveIncidentPayload, + RegradeOutcome, } from '../types/exam'; const examApi = baseApi.injectEndpoints({ @@ -92,6 +93,14 @@ const examApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + /** Staff-triggered re-run of auto-grading for one finalized attempt. */ + regradeAttempt: builder.mutation({ + query: (attemptId) => ({ + url: `/exam-attempts/${attemptId}/regrade`, + method: 'POST', + }), + invalidatesTags: ['Api'], + }), }), overrideExisting: false, }); @@ -109,4 +118,5 @@ export const { useGetExamIncidentsQuery, useRecordIncidentMutation, useResolveIncidentMutation, + useRegradeAttemptMutation, } = examApi; diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx index 421a39c02..7501723ef 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx @@ -1,5 +1,5 @@ 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 { AdvancedColumn } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; @@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) => export function examCandidateColumns( t: TFunction, - handlers: { onRecord: (registration: ExamRegistration) => void }, + handlers: { + onRecord: (registration: ExamRegistration) => void; + onRegrade: (registration: ExamRegistration) => void; + regrading?: string | null; + }, ): AdvancedColumn[] { return [ { @@ -78,21 +82,45 @@ export function examCandidateColumns( header: '', label: t('exam.candidates.record'), align: 'right', - cell: ({ row }) => ( - - - - ), + cell: ({ row }) => { + const attemptStatus = row.original.attempt?.status; + const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED'; + return ( + <> + + + + {canRegrade && ( + + + + )} + + ); + }, }, ]; } diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx index e02dad17c..4c11d1b7b 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx @@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api'; import { useGetExamRegistrationsQuery, useRecordAttendanceMutation, + useRegradeAttemptMutation, } from '../../api/exam-api'; import type { AttendanceStatus, ExamRegistration } from '../../types/exam'; import { candidateName, examCandidateColumns } from './columns'; @@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) { const { t } = useTranslation(); const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId); const [recordAttendance, { isLoading }] = useRecordAttendanceMutation(); + const [regradeAttempt] = useRegradeAttemptMutation(); + const [regrading, setRegrading] = useState(null); const [target, setTarget] = useState(null); const [status, setStatus] = useState('PRESENT'); const [remark, setRemark] = useState(''); 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) => { setTarget(registration); setStatus( @@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) { ) : ( { 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 ( + + Completed + + ); + } + if (attemptStatus === 'EXPIRED') { + return ( + + Time expired + + ); + } const eligible = exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus); if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null; @@ -104,7 +121,7 @@ export function registrationColumns(deps: { leftSection={} onClick={() => deps.onStartExam(row.original)} > - Take exam + {attemptStatus === 'IN_PROGRESS' ? 'Resume exam' : 'Take exam'} ); }, diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx index 5a5c081d9..067012ad5 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx @@ -54,6 +54,8 @@ export interface MyRegistration { attemptNumber: number; attendanceStatus: AttendanceStatus; exam?: OpenExam; + /** The candidate's online sitting, when one has been started. */ + attempt?: { status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null; } export interface MyResult {