Files
emaui/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx
mihretue 83d309248d feat(exam): regrade action + stop inviting a re-take on a finished exam
Two fixes surfaced by actual candidate-flow testing:

- Portal 'My registrations' always showed 'Take exam' regardless of whether
  the candidate had already finished — clicking it just hit
  attempt_already_submitted server-side. The registrations/mine response
  now carries the attempt's status; the row shows a Completed/Time expired
  badge once finished, or 'Resume exam' while still in progress, and only
  invites a fresh start when there's genuinely nothing yet.
- Backoffice candidates panel: a Regrade button next to a SUBMITTED/EXPIRED
  sitting, calling the new POST /exam-attempts/:id/regrade — the officer's
  path to the same staff-triggered regrade, no SQL needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 12:00:45 +00:00

127 lines
3.8 KiB
TypeScript

import { Badge, Button, Text } from '@mantine/core';
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';
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',
};
export const candidateName = (registration: ExamRegistration) =>
[
registration.profile?.firstName,
registration.profile?.middleName,
registration.profile?.lastName,
]
.filter(Boolean)
.join(' ') || registration.profileId.slice(0, 8);
export function examCandidateColumns(
t: TFunction,
handlers: {
onRecord: (registration: ExamRegistration) => void;
onRegrade: (registration: ExamRegistration) => void;
regrading?: string | null;
},
): AdvancedColumn<ExamRegistration>[] {
return [
{
header: t('exam.candidates.admission'),
cell: ({ row }) => (
<Text fz="sm" ff="monospace" fw={600}>
{row.original.admissionNumber}
</Text>
),
},
{
header: t('exam.candidates.name'),
cell: ({ row }) => <Text fz="sm">{candidateName(row.original)}</Text>,
},
{
header: t('exam.candidates.attempt'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{row.original.kind === 'RETAKE'
? t('exam.candidates.retake', { n: row.original.attemptNumber })
: t('exam.candidates.firstSitting')}
</Badge>
),
},
{
header: t('exam.candidates.attendance'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
>
{t(`exam.attendance.${row.original.attendanceStatus}`)}
</Badge>
),
},
{
header: t('exam.candidates.remark'),
cell: ({ row }) => (
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
{row.original.attendanceRemark ?? '—'}
</Text>
),
},
{
header: '',
label: t('exam.candidates.record'),
align: 'right',
cell: ({ row }) => {
const attemptStatus = row.original.attempt?.status;
const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED';
return (
<>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.RECORD_EXAM_ATTENDANCE]}
hideOnly
>
<Button
size="compact-xs"
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>
)}
</>
);
},
},
];
}