Merge branch 'feature/exam-attempt-domain' of github.com:Tria-plc/emaui into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-17 15:02:08 +03:00
8 changed files with 116 additions and 19 deletions

View File

@@ -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<RegradeOutcome, string>({
query: (attemptId) => ({
url: `/exam-attempts/${attemptId}/regrade`,
method: 'POST',
}),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
@@ -109,4 +118,5 @@ export const {
useGetExamIncidentsQuery,
useRecordIncidentMutation,
useResolveIncidentMutation,
useRegradeAttemptMutation,
} = examApi;

View File

@@ -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<ExamRegistration>[] {
return [
{
@@ -78,7 +82,11 @@ 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 (
<>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
hideOnly
@@ -88,11 +96,31 @@ export function examCandidateColumns(
variant="light"
leftSection={<IconUserCheck size={12} />}
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 {
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<string | null>(null);
const [target, setTarget] = useState<ExamRegistration | null>(null);
const [status, setStatus] = useState<AttendanceStatus>('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 }) {
) : (
<AdvancedTable
tableName={t('exam.candidates.section')}
columns={examCandidateColumns(t, { onRecord: startRecording })}
columns={examCandidateColumns(t, {
onRecord: startRecording,
onRegrade: regrade,
regrading,
})}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}

View File

@@ -126,8 +126,14 @@ export interface ExamRegistration {
lastName: 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 {
registrationId: string;
status: AttendanceStatus;

View File

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

View File

@@ -322,6 +322,10 @@ export const en = {
retake: 'Retake {{n}}',
firstSitting: 'First sitting',
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: {
REGISTERED: 'Not called',

View File

@@ -94,6 +94,23 @@ export function registrationColumns(deps: {
header: 'Exam',
cell: ({ row }) => {
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 =
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={<IconPlayerPlay size={13} />}
onClick={() => deps.onStartExam(row.original)}
>
Take exam
{attemptStatus === 'IN_PROGRESS' ? 'Resume exam' : 'Take exam'}
</Button>
);
},

View File

@@ -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 {