mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 21:15:42 +00:00
Adding more functionalities for all the license types
This commit is contained in:
@@ -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<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user