diff --git a/apps/backoffice/src/app/features/coc-queue/pages/CoCQueuePage.tsx b/apps/backoffice/src/app/features/coc-queue/pages/CoCQueuePage.tsx deleted file mode 100644 index 428120fcc..000000000 --- a/apps/backoffice/src/app/features/coc-queue/pages/CoCQueuePage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Container } from '@mantine/core'; -import { FeatureUnavailable } from '@ema-platform/ui'; - -/** - * Placeholder until this feature has a backend. - * - * This page previously rendered hardcoded sample records, which were - * indistinguishable from real ones. - */ -export function CoCQueuePage() { - return ( - - - - ); -} - -export default CoCQueuePage; diff --git a/apps/backoffice/src/app/features/coc-queue/pages/CoCReviewPage.tsx b/apps/backoffice/src/app/features/coc-queue/pages/CoCReviewPage.tsx deleted file mode 100644 index 79d23868d..000000000 --- a/apps/backoffice/src/app/features/coc-queue/pages/CoCReviewPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Container } from '@mantine/core'; -import { FeatureUnavailable } from '@ema-platform/ui'; - -/** - * Placeholder until this feature has a backend. - * - * This page previously rendered hardcoded sample records, which were - * indistinguishable from real ones. - */ -export function CoCReviewPage() { - return ( - - - - ); -} - -export default CoCReviewPage; 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 a99d06399..83dbd473b 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -5,6 +5,12 @@ import type { CreateExamPayload, UpdateExamPayload, AssignQuestionsPayload, + RandomQuestionsPayload, + ExamRegistration, + RecordAttendancePayload, + ExamIncident, + CreateIncidentPayload, + ResolveIncidentPayload, } from '../types/exam'; const examApi = baseApi.injectEndpoints({ @@ -41,6 +47,49 @@ const examApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + /** Server-side draw from the approved bank (US-EXAM-005). */ + selectRandomQuestions: builder.mutation({ + query: ({ examId, count }) => ({ + url: `/exams/${examId}/questions/random`, + method: 'POST', + body: { count }, + }), + invalidatesTags: ['Api'], + }), + // --- Candidates and attendance (US-EXAM-007/009) --------------------- + getExamRegistrations: builder.query({ + query: (examId) => `/exams/${examId}/registrations`, + providesTags: ['Api'], + }), + recordAttendance: builder.mutation({ + query: ({ registrationId, ...body }) => ({ + url: `/exams/registrations/${registrationId}/attendance`, + method: 'POST', + body, + }), + invalidatesTags: ['Api'], + }), + // --- Session incidents (US-EXAM-010) --------------------------------- + getExamIncidents: builder.query({ + query: (examId) => `/exams/${examId}/incidents`, + providesTags: ['Api'], + }), + recordIncident: builder.mutation({ + query: ({ examId, ...body }) => ({ + url: `/exams/${examId}/incidents`, + method: 'POST', + body, + }), + invalidatesTags: ['Api'], + }), + resolveIncident: builder.mutation({ + query: ({ incidentId, ...body }) => ({ + url: `/exams/incidents/${incidentId}/resolve`, + method: 'POST', + body, + }), + invalidatesTags: ['Api'], + }), }), overrideExisting: false, }); @@ -52,4 +101,10 @@ export const { useUpdateExamMutation, useDeleteExamMutation, useAssignQuestionsMutation, + useSelectRandomQuestionsMutation, + useGetExamRegistrationsQuery, + useRecordAttendanceMutation, + useGetExamIncidentsQuery, + useRecordIncidentMutation, + useResolveIncidentMutation, } = examApi; diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel.tsx new file mode 100644 index 000000000..3660b77c9 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Alert, + Badge, + Button, + Group, + Modal, + Paper, + Select, + Stack, + Table, + Text, + Textarea, + Title, +} from '@mantine/core'; +import { IconInfoCircle, IconUserCheck } from '@tabler/icons-react'; +import { notify } from '@ema-platform/ui'; +import { extractErrorMessage } from '@ema-platform/api'; +import { + useGetExamRegistrationsQuery, + useRecordAttendanceMutation, +} from '../api/exam-api'; +import type { AttendanceStatus, ExamRegistration } from '../types/exam'; + +const ATTENDANCE_COLOR: Record = { + REGISTERED: 'gray', + PRESENT: 'teal', + LATE: 'yellow', + ABSENT: 'red', + WITHDRAWN: 'orange', + DISQUALIFIED: 'red', +}; + +/** Rulings that end the sitting, and so must be explained (US-EXAM-009). */ +const NEEDS_REMARK: AttendanceStatus[] = ['WITHDRAWN', 'DISQUALIFIED']; + +const OPTIONS: AttendanceStatus[] = [ + 'PRESENT', + 'LATE', + 'ABSENT', + 'WITHDRAWN', + 'DISQUALIFIED', +]; + +/** + * The invigilator's register for one session (US-EXAM-009). + * + * Marking a paper later depends on what is recorded here: an absent, + * withdrawn or disqualified candidate has no result to enter. + */ +export function ExamCandidatesPanel({ examId }: { examId: string }) { + const { t } = useTranslation(); + const { data: registrations, isError } = useGetExamRegistrationsQuery(examId); + const [recordAttendance, { isLoading }] = useRecordAttendanceMutation(); + const [target, setTarget] = useState(null); + const [status, setStatus] = useState('PRESENT'); + const [remark, setRemark] = useState(''); + + const candidateName = (registration: ExamRegistration) => + [ + registration.profile?.firstName, + registration.profile?.middleName, + registration.profile?.lastName, + ] + .filter(Boolean) + .join(' ') || registration.profileId.slice(0, 8); + + const save = async () => { + if (!target) return; + if (NEEDS_REMARK.includes(status) && !remark.trim()) { + notify.error(t('exam.candidates.remarkRequired')); + return; + } + try { + await recordAttendance({ + registrationId: target.id, + status, + remark: remark.trim() || undefined, + }).unwrap(); + notify.success(t('exam.candidates.recorded')); + setTarget(null); + setRemark(''); + } catch (error) { + notify.error(extractErrorMessage(error, t('exam.incidents.error'))); + } + }; + + // Officers without the invigilation permission simply do not see the + // register; the endpoint refuses them and there is nothing to show. + if (isError) return null; + + return ( + + + {t('exam.candidates.section')} + + {(registrations ?? []).length === 0 ? ( + }> + {t('exam.candidates.none')} + + ) : ( + + + + {t('exam.candidates.admission')} + {t('exam.candidates.name')} + {t('exam.candidates.attempt')} + {t('exam.candidates.attendance')} + {t('exam.candidates.remark')} + + + + + {(registrations ?? []).map((registration) => ( + + + + {registration.admissionNumber} + + + + {candidateName(registration)} + + + + {registration.kind === 'RETAKE' + ? t('exam.candidates.retake', { n: registration.attemptNumber }) + : t('exam.candidates.firstSitting')} + + + + + {t(`exam.attendance.${registration.attendanceStatus}`)} + + + + + {registration.attendanceRemark ?? '—'} + + + + + + + ))} + +
+ )} + + setTarget(null)} + title={t('exam.candidates.attendance')} + size="md" + radius="lg" + > + + + {target ? candidateName(target) : ''} · {target?.admissionNumber} + +