Files
emaui/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel.tsx
2026-08-07 11:50:07 +03:00

216 lines
6.9 KiB
TypeScript

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<AttendanceStatus, string> = {
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<ExamRegistration | null>(null);
const [status, setStatus] = useState<AttendanceStatus>('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 (
<Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">
{t('exam.candidates.section')}
</Title>
{(registrations ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
{t('exam.candidates.none')}
</Alert>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('exam.candidates.admission')}</Table.Th>
<Table.Th>{t('exam.candidates.name')}</Table.Th>
<Table.Th>{t('exam.candidates.attempt')}</Table.Th>
<Table.Th>{t('exam.candidates.attendance')}</Table.Th>
<Table.Th>{t('exam.candidates.remark')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(registrations ?? []).map((registration) => (
<Table.Tr key={registration.id}>
<Table.Td>
<Text fz="sm" ff="monospace" fw={600}>
{registration.admissionNumber}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{candidateName(registration)}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{registration.kind === 'RETAKE'
? t('exam.candidates.retake', { n: registration.attemptNumber })
: t('exam.candidates.firstSitting')}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
>
{t(`exam.attendance.${registration.attendanceStatus}`)}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
{registration.attendanceRemark ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="light"
leftSection={<IconUserCheck size={12} />}
onClick={() => {
setTarget(registration);
setStatus(
registration.attendanceStatus === 'REGISTERED'
? 'PRESENT'
: registration.attendanceStatus,
);
setRemark(registration.attendanceRemark ?? '');
}}
>
{t('exam.candidates.record')}
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Modal
opened={Boolean(target)}
onClose={() => setTarget(null)}
title={t('exam.candidates.attendance')}
size="md"
radius="lg"
>
<Stack gap="sm">
<Text fz="sm" fw={500}>
{target ? candidateName(target) : ''} · {target?.admissionNumber}
</Text>
<Select
label={t('exam.candidates.attendance')}
data={OPTIONS.map((value) => ({
value,
label: t(`exam.attendance.${value}`),
}))}
value={status}
onChange={(value) => setStatus((value as AttendanceStatus) ?? 'PRESENT')}
size="sm"
/>
<Textarea
label={t('exam.candidates.remark')}
minRows={2}
autosize
value={remark}
onChange={(event) => setRemark(event.currentTarget.value)}
required={NEEDS_REMARK.includes(status)}
/>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setTarget(null)}>
{t('exam.cancel')}
</Button>
<Button size="sm" loading={isLoading} onClick={save}>
{t('exam.candidates.record')}
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}