feat(vessel-registration): add vessel registration page with incident reporting and certificate download functionality

feat(waiver): implement waiver page with application tracking and letter download feature

feat(ui): introduce AdvancedTable component for enhanced table functionality across the application

chore: update package-lock.json to remove unnecessary dependencies
This commit is contained in:
Nati
2026-08-13 09:11:50 +00:00
parent a7e74cb7bd
commit 2af9e60800
67 changed files with 4209 additions and 3446 deletions

View File

@@ -0,0 +1,55 @@
import { NumberInput, Text, TextInput } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedTableColumn } from '@ema-platform/ui';
import type { QuestionBrief } from '../../../exam/types/exam';
export function recordResultColumns(
t: TFunction,
locale: 'en' | 'am',
handlers: {
scores: Record<string, number>;
questionRemarks: Record<string, string>;
onScoreChange: (questionId: string, value: number) => void;
onRemarkChange: (questionId: string, value: string) => void;
},
): AdvancedTableColumn<QuestionBrief>[] {
return [
{
key: 'question',
header: t('result.recordModal.question'),
render: (q) => <Text fz="sm" maw={250} lineClamp={2}>{q.title[locale]}</Text>,
},
{
key: 'maxPoints',
header: t('result.recordModal.maxPoints'),
render: (q) => <Text fz="sm" fw={600}>{q.points}</Text>,
},
{
key: 'score',
header: t('result.recordModal.score'),
render: (q) => (
<NumberInput
value={handlers.scores[q.id] ?? 0}
onChange={(v) => handlers.onScoreChange(q.id, Number(v))}
min={0}
max={q.points}
size="xs"
style={{ width: 80 }}
/>
),
},
{
key: 'remark',
header: t('result.recordModal.remark'),
render: (q) => (
<TextInput
placeholder={t('result.recordModal.remarkOptional')}
value={handlers.questionRemarks[q.id] ?? ''}
onChange={(e) => handlers.onRemarkChange(q.id, e.currentTarget.value)}
size="xs"
style={{ minWidth: 160 }}
/>
),
},
];
}

View File

@@ -5,10 +5,8 @@ import {
Stack,
Select,
Divider,
Table,
Text,
Badge,
NumberInput,
Paper,
SimpleGrid,
Group,
@@ -17,11 +15,12 @@ import {
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { AdvancedTable, notify } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { useCreateResultMutation } from '../api/result-api';
import { useGetExamRegistrationsQuery } from '../../exam/api/exam-api';
import type { Exam } from '../../exam/types/exam';
import { recordResultColumns } from './columns';
import { useCreateResultMutation } from '../../api/result-api';
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
import type { Exam } from '../../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) {
return (
@@ -166,43 +165,16 @@ export function RecordResultModal({
{selectedSeafarerId && questions.length > 0 && (
<>
<Divider label={t('result.recordModal.scorePerQuestion')} labelPosition="center" />
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('result.recordModal.question')}</Table.Th>
<Table.Th>{t('result.recordModal.maxPoints')}</Table.Th>
<Table.Th>{t('result.recordModal.score')}</Table.Th>
<Table.Th>{t('result.recordModal.remark')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{questions.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<NumberInput
value={scores[q.id] ?? 0}
onChange={(v) => handleScoreChange(q.id, Number(v))}
min={0}
max={q.points}
size="xs"
style={{ width: 80 }}
/>
</Table.Td>
<Table.Td>
<TextInput
placeholder={t('result.recordModal.remarkOptional')}
value={questionRemarks[q.id] ?? ''}
onChange={(e) => handleQuestionRemarkChange(q.id, e.currentTarget.value)}
size="xs"
style={{ minWidth: 160 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<AdvancedTable
columns={recordResultColumns(t, locale, {
scores,
questionRemarks,
onScoreChange: handleScoreChange,
onRemarkChange: handleQuestionRemarkChange,
})}
data={questions}
rowKey={(q) => q.id}
/>
<Paper withBorder p="sm" radius="md" bg="gray.0">
<SimpleGrid cols={3} spacing="sm">

View File

@@ -0,0 +1,77 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconGavel } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedTableColumn } from '@ema-platform/ui';
import type { ExamAppeal } from '../../types/result';
export function examAppealsColumns(
t: TFunction,
locale: 'en' | 'am',
handlers: { onDecide: (appeal: ExamAppeal) => void },
): AdvancedTableColumn<ExamAppeal>[] {
return [
{
key: 'number',
header: t('result.appeals.number'),
render: (appeal) => (
<Text fz="sm" ff="monospace" fw={600}>
{appeal.appealNumber}
</Text>
),
},
{
key: 'candidate',
header: t('result.appeals.candidate'),
render: (appeal) => (
<Text fz="sm">
{appeal.profile
? `${appeal.profile.firstName} ${appeal.profile.lastName}`
: appeal.profileId.slice(0, 8)}
</Text>
),
},
{
key: 'exam',
header: t('result.appeals.exam'),
render: (appeal) => (
<>
<Text fz="sm">
{appeal.result?.exam?.title?.[locale] ?? '—'}
</Text>
<Badge size="xs" variant="light" color="gray">
{appeal.result?.status} · {appeal.result?.totalScore}
</Badge>
</>
),
},
{
key: 'reason',
header: t('result.appeals.reason'),
render: (appeal) => (
<Text fz="xs" maw={300} lineClamp={3}>
{appeal.reason}
</Text>
),
},
{
key: 'lodged',
header: t('result.appeals.lodged'),
render: (appeal) => <Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>,
},
{
key: 'actions',
header: '',
render: (appeal) => (
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onDecide(appeal)}
>
{t('result.appeals.decide')}
</Button>
),
},
];
}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Center,
Group,
@@ -11,19 +10,19 @@ import {
Paper,
Select,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconGavel, IconInfoCircle } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetPendingAppealsQuery,
useDecideAppealMutation,
} from '../api/result-api';
import type { ExamAppeal } from '../types/result';
} from '../../api/result-api';
import { examAppealsColumns } from './columns';
import type { ExamAppeal } from '../../types/result';
/**
* The appeals desk (US-EXAM-016).
@@ -35,7 +34,7 @@ import type { ExamAppeal } from '../types/result';
export function ExamAppealsPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery();
const { data: appeals, isLoading, isError, refetch } = useGetPendingAppealsQuery();
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
const [target, setTarget] = useState<ExamAppeal | null>(null);
@@ -83,73 +82,19 @@ export function ExamAppealsPage() {
</div>
<Paper withBorder radius="md">
{(appeals ?? []).length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('result.appeals.none')}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('result.appeals.number')}</Table.Th>
<Table.Th>{t('result.appeals.candidate')}</Table.Th>
<Table.Th>{t('result.appeals.exam')}</Table.Th>
<Table.Th>{t('result.appeals.reason')}</Table.Th>
<Table.Th>{t('result.appeals.lodged')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(appeals ?? []).map((appeal) => (
<Table.Tr key={appeal.id}>
<Table.Td>
<Text fz="sm" ff="monospace" fw={600}>
{appeal.appealNumber}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">
{appeal.profile
? `${appeal.profile.firstName} ${appeal.profile.lastName}`
: appeal.profileId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">
{appeal.result?.exam?.title?.[locale] ?? '—'}
</Text>
<Badge size="xs" variant="light" color="gray">
{appeal.result?.status} · {appeal.result?.totalScore}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="xs" maw={300} lineClamp={3}>
{appeal.reason}
</Text>
</Table.Td>
<Table.Td>
<Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={12} />}
onClick={() => {
setTarget(appeal);
setOutcome('UPHELD');
setRemark('');
}}
>
{t('result.appeals.decide')}
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<AdvancedTable
columns={examAppealsColumns(t, locale, {
onDecide: (appeal) => {
setTarget(appeal);
setOutcome('UPHELD');
setRemark('');
},
})}
data={appeals ?? []}
rowKey={(appeal) => appeal.id}
onRefresh={refetch}
emptyTitle={t('result.appeals.none')}
/>
</Paper>
<Modal

View File

@@ -0,0 +1,221 @@
import { Badge, Box, Button, Group, Text, TextInput } from '@mantine/core';
import { IconEye, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedTableColumn } from '@ema-platform/ui';
import type { Result, ResultBreakdown, ResultReviewStatus } from '../../types/result';
import type { QuestionBrief } from '../../../exam/types/exam';
export const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
FAILED: 'red',
};
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
export const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
MARKED: 'gray',
MODERATED: 'yellow',
APPROVED: 'blue',
PUBLISHED: 'teal',
};
export type QcAction = 'moderate' | 'approve' | 'return';
export function resultColumns(
t: TFunction,
locale: 'en' | 'am',
handlers: {
getExamTitle: (id: string) => string;
onQc: (result: Result, action: QcAction) => void;
onViewDetail: (result: Result) => void;
onDelete: (result: Result) => void;
},
): AdvancedTableColumn<Result>[] {
return [
{
key: 'seafarer',
header: t('result.columns.seafarer'),
render: (r) => (
<Text fz="sm" fw={500}>
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
</Text>
),
},
{
key: 'exam',
header: t('result.columns.exam'),
render: (r) => (
<Text fz="sm">{r.exam ? r.exam.title[locale] : handlers.getExamTitle(r.examId)}</Text>
),
},
{
key: 'totalScore',
header: t('result.columns.totalScore'),
render: (r) => <Text fz="sm" fw={600}>{r.totalScore}</Text>,
},
{
key: 'status',
header: t('result.columns.status'),
render: (r) => (
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
/>
}
>
{t(`result.status.${r.status}`)}
</Badge>
),
},
{
key: 'review',
header: t('result.review.column'),
render: (r) => (
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
{t(`result.review.${r.reviewStatus}`)}
</Badge>
),
},
{
key: 'date',
header: t('result.columns.date'),
render: (r) => <Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text>,
},
{
key: 'actions',
header: '',
render: (r) => (
<Group gap="xs">
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<>
<Button
size="compact-xs"
variant="light"
color="yellow"
onClick={() => handlers.onQc(r, 'moderate')}
>
{t('result.review.moderate')}
</Button>
<Button
size="compact-xs"
variant="light"
color="blue"
onClick={() => handlers.onQc(r, 'approve')}
>
{t('result.review.approve')}
</Button>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<Button
size="compact-xs"
variant="subtle"
color="orange"
onClick={() => handlers.onQc(r, 'return')}
>
{t('result.review.return')}
</Button>
)}
<Button
size="xs"
variant="subtle"
leftSection={<IconEye size={13} />}
onClick={() => handlers.onViewDetail(r)}
>
{t('result.action.viewEdit')}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => handlers.onDelete(r)}
>
{t('result.action.delete')}
</Button>
</Group>
),
},
];
}
export function resultBreakdownColumns(
t: TFunction,
locale: 'en' | 'am',
handlers: {
breakdowns: ResultBreakdown[];
questions?: QuestionBrief[];
onChange: (breakdowns: ResultBreakdown[]) => void;
},
): AdvancedTableColumn<ResultBreakdown>[] {
const { breakdowns, questions, onChange } = handlers;
const indexOf = (b: ResultBreakdown) =>
breakdowns.findIndex((x) => x.questionId === b.questionId);
return [
{
key: 'index',
header: '#',
render: (b) => <Text fz="xs">{indexOf(b) + 1}</Text>,
},
{
key: 'question',
header: t('result.detail.question'),
render: (b) => {
const q = questions?.find((eq) => eq.id === b.questionId);
return (
<Text fz="xs" lineClamp={2} maw={200}>
{q?.title?.[locale] ?? b.questionId.slice(0, 8)}
</Text>
);
},
},
{
key: 'max',
header: t('result.detail.max'),
render: (b) => {
const q = questions?.find((eq) => eq.id === b.questionId);
return <Text fz="sm" fw={600}>{q?.points ?? '—'}</Text>;
},
},
{
key: 'score',
header: t('result.detail.score'),
render: (b) => (
<TextInput
size="xs"
type="number"
style={{ width: 80 }}
value={b.score}
onChange={(e) => {
const i = indexOf(b);
const updated = [...breakdowns];
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
onChange(updated);
}}
/>
),
},
{
key: 'remark',
header: t('result.detail.remarkShort'),
render: (b) => (
<TextInput
size="xs"
placeholder="Optional"
value={b.remark ?? ''}
onChange={(e) => {
const i = indexOf(b);
const updated = [...breakdowns];
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
onChange(updated);
}}
/>
),
},
];
}

View File

@@ -4,7 +4,6 @@ import {
Stack,
Title,
Group,
Table,
Badge,
Modal,
Text,
@@ -17,14 +16,11 @@ import {
Divider,
Button,
ThemeIcon,
Box,
TextInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconInfoCircle,
IconEye,
IconTrash,
IconUser,
IconCertificate,
IconDeviceFloppy,
@@ -36,7 +32,7 @@ import {
IconSearch,
IconSend,
} from '@tabler/icons-react';
import { notify, BilingualInput } from '@ema-platform/ui';
import { AdvancedTable, notify, BilingualInput } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import {
@@ -48,26 +44,18 @@ import {
useApproveResultMutation,
useReturnResultMutation,
usePublishExamResultsMutation,
} from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
import { RecordResultModal } from '../components/RecordResultModal';
import type { Result, ResultBreakdown, ResultReviewStatus } from '../types/result';
import type { Exam } from '../../exam/types/exam';
const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
FAILED: 'red',
};
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
MARKED: 'gray',
MODERATED: 'yellow',
APPROVED: 'blue',
PUBLISHED: 'teal',
};
type QcAction = 'moderate' | 'approve' | 'return';
} from '../../api/result-api';
import { useGetExamsQuery } from '../../../exam/api/exam-api';
import { RecordResultModal } from '../../components/RecordResultModal';
import {
STATUS_COLOR,
REVIEW_COLOR,
resultColumns,
resultBreakdownColumns,
type QcAction,
} from './columns';
import type { Result, ResultBreakdown } from '../../types/result';
import type { Exam } from '../../../exam/types/exam';
function ResultStat({
label,
@@ -117,7 +105,7 @@ export function ResultPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: examRes } = useGetExamsQuery();
const { data, isLoading, isError } = useGetResultsQuery();
const { data, isLoading, isError, refetch } = useGetResultsQuery();
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
const exams = examRes?.items ?? [];
@@ -348,112 +336,18 @@ export function ResultPage() {
</Group>
</Group>
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
<Table.Th>{t('result.columns.exam')}</Table.Th>
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
<Table.Th>{t('result.columns.status')}</Table.Th>
<Table.Th>{t('result.review.column')}</Table.Th>
<Table.Th>{t('result.columns.date')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td><Text fz="sm">{r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
/>
}
>
{t(`result.status.${r.status}`)}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
{t(`result.review.${r.reviewStatus}`)}
</Badge>
</Table.Td>
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
<Table.Td>
<Group gap="xs">
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
<>
<Button
size="compact-xs"
variant="light"
color="yellow"
onClick={() => openQc(r, 'moderate')}
>
{t('result.review.moderate')}
</Button>
<Button
size="compact-xs"
variant="light"
color="blue"
onClick={() => openQc(r, 'approve')}
>
{t('result.review.approve')}
</Button>
</>
)}
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
<Button
size="compact-xs"
variant="subtle"
color="orange"
onClick={() => openQc(r, 'return')}
>
{t('result.review.return')}
</Button>
)}
<Button
size="xs"
variant="subtle"
leftSection={<IconEye size={13} />}
onClick={() => viewDetail(r)}
>
{t('result.action.viewEdit')}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => { setDeleteTarget(r); openDelete(); }}
>
{t('result.action.delete')}
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<AdvancedTable
columns={resultColumns(t, locale, {
getExamTitle,
onQc: openQc,
onViewDetail: viewDetail,
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
})}
data={filtered}
rowKey={(r) => r.id}
onRefresh={refetch}
emptyTitle={t('result.noItems')}
/>
</Paper>
<Modal
@@ -537,58 +431,15 @@ export function ResultPage() {
{detailBreakdowns.length > 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{t('result.detail.scoreBreakdown')}</Text>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>#</Table.Th>
<Table.Th>{t('result.detail.question')}</Table.Th>
<Table.Th>{t('result.detail.max')}</Table.Th>
<Table.Th>{t('result.detail.score')}</Table.Th>
<Table.Th>{t('result.detail.remarkShort')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{detailBreakdowns.map((b, i) => {
const examDetail = exams.find((e) => e.id === detailResult.examId);
const q = examDetail?.questions?.find((eq) => eq.id === b.questionId);
return (
<Table.Tr key={b.questionId}>
<Table.Td><Text fz="xs">{i + 1}</Text></Table.Td>
<Table.Td>
<Text fz="xs" lineClamp={2} maw={200}>
{q?.title?.[locale] ?? b.questionId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
<Table.Td>
<TextInput
size="xs"
type="number"
style={{ width: 80 }}
value={b.score}
onChange={(e) => {
const updated = [...detailBreakdowns];
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
setDetailBreakdowns(updated);
}}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="Optional"
value={b.remark ?? ''}
onChange={(e) => {
const updated = [...detailBreakdowns];
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
setDetailBreakdowns(updated);
}}
/>
</Table.Td>
</Table.Tr>
);})}
</Table.Tbody>
</Table>
<AdvancedTable
columns={resultBreakdownColumns(t, locale, {
breakdowns: detailBreakdowns,
questions: exams.find((e) => e.id === detailResult.examId)?.questions,
onChange: setDetailBreakdowns,
})}
data={detailBreakdowns}
rowKey={(b) => b.questionId}
/>
</>
)}