Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor

Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical
table component: kept its API plus teammate i18n/feature work, kept the
folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all
27 tables, removed the parallel lib/table implementation, and fixed
pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Nati
2026-08-13 11:31:27 +00:00
182 changed files with 14181 additions and 8141 deletions

View File

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

View File

@@ -15,7 +15,7 @@ import {
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { AdvancedTable, notify } from '@ema-platform/ui';
import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { recordResultColumns } from './columns';
import { useCreateResultMutation } from '../../api/result-api';
@@ -55,8 +55,10 @@ export function RecordResultModal({
skip: !opened,
});
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const table = useServerTable();
const questions = exam.questions ?? [];
const pagedQuestions = table.paginate(questions);
const seafarerOptions = (registrations ?? [])
.filter((registration) =>
@@ -166,14 +168,18 @@ export function RecordResultModal({
<>
<Divider label={t('result.recordModal.scorePerQuestion')} labelPosition="center" />
<AdvancedTable
tableName={t('result.recordModal.title')}
columns={recordResultColumns(t, locale, {
scores,
questionRemarks,
onScoreChange: handleScoreChange,
onRemarkChange: handleQuestionRemarkChange,
})}
data={questions}
rowKey={(q) => q.id}
data={pagedQuestions.rows}
itemCount={pagedQuestions.itemCount}
pageIndex={pagedQuestions.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
/>
<Paper withBorder p="sm" radius="md" bg="gray.0">
@@ -199,12 +205,12 @@ export function RecordResultModal({
size="sm"
/>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}>
{t('result.saveResult')}
</Button>
</Group>
</ModalFooter>
</>
)}

View File

@@ -1,73 +1,70 @@
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 { AdvancedColumn } from '@ema-platform/ui';
import type { ExamAppeal } from '../../types/result';
export function examAppealsColumns(
t: TFunction,
locale: 'en' | 'am',
showDate: (date: string | null | undefined) => string,
handlers: { onDecide: (appeal: ExamAppeal) => void },
): AdvancedTableColumn<ExamAppeal>[] {
): AdvancedColumn<ExamAppeal>[] {
return [
{
key: 'number',
header: t('result.appeals.number'),
render: (appeal) => (
cell: ({ row }) => (
<Text fz="sm" ff="monospace" fw={600}>
{appeal.appealNumber}
{row.original.appealNumber}
</Text>
),
},
{
key: 'candidate',
header: t('result.appeals.candidate'),
render: (appeal) => (
cell: ({ row }) => (
<Text fz="sm">
{appeal.profile
? `${appeal.profile.firstName} ${appeal.profile.lastName}`
: appeal.profileId.slice(0, 8)}
{row.original.profile
? `${row.original.profile.firstName} ${row.original.profile.lastName}`
: row.original.profileId.slice(0, 8)}
</Text>
),
},
{
key: 'exam',
header: t('result.appeals.exam'),
render: (appeal) => (
cell: ({ row }) => (
<>
<Text fz="sm">
{appeal.result?.exam?.title?.[locale] ?? '—'}
{row.original.result?.exam?.title?.[locale] ?? '—'}
</Text>
<Badge size="xs" variant="light" color="gray">
{appeal.result?.status} · {appeal.result?.totalScore}
{row.original.result?.status} · {row.original.result?.totalScore}
</Badge>
</>
),
},
{
key: 'reason',
header: t('result.appeals.reason'),
render: (appeal) => (
cell: ({ row }) => (
<Text fz="xs" maw={300} lineClamp={3}>
{appeal.reason}
{row.original.reason}
</Text>
),
},
{
key: 'lodged',
header: t('result.appeals.lodged'),
render: (appeal) => <Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>,
cell: ({ row }) => <Text fz="xs">{showDate(row.original.createdAt)}</Text>,
},
{
key: 'actions',
header: '',
render: (appeal) => (
label: t('result.appeals.decide'),
align: 'right',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={12} />}
onClick={() => handlers.onDecide(appeal)}
onClick={() => handlers.onDecide(row.original)}
>
{t('result.appeals.decide')}
</Button>

View File

@@ -15,7 +15,8 @@ import {
Title,
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api';
import {
useGetPendingAppealsQuery,
@@ -34,7 +35,9 @@ import type { ExamAppeal } from '../../types/result';
export function ExamAppealsPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const showDate = useDateDisplayer();
const { data: appeals, isLoading, isError, refetch } = useGetPendingAppealsQuery();
const table = useServerTable();
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
const [target, setTarget] = useState<ExamAppeal | null>(null);
@@ -72,6 +75,8 @@ export function ExamAppealsPage() {
);
}
const paged = table.paginate(appeals ?? []);
return (
<Stack gap="lg">
<div>
@@ -82,18 +87,22 @@ export function ExamAppealsPage() {
</div>
<Paper withBorder radius="md">
<AdvancedTable
columns={examAppealsColumns(t, locale, {
<AdvancedTable<ExamAppeal>
tableName={t('result.appeals.title')}
columns={examAppealsColumns(t, locale, showDate, {
onDecide: (appeal) => {
setTarget(appeal);
setOutcome('UPHELD');
setRemark('');
},
})}
data={appeals ?? []}
rowKey={(appeal) => appeal.id}
onRefresh={refetch}
emptyTitle={t('result.appeals.none')}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
refresh={refetch}
emptyText={t('result.appeals.none')}
/>
</Paper>

View File

@@ -0,0 +1,55 @@
import { Button, Group } from '@mantine/core';
import { IconEye, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Result } from '../../types/result';
export type QcAction = 'moderate' | 'approve' | 'return';
export function resultActionsColumn(
t: TFunction,
handlers: {
onQc: (result: Result, action: QcAction) => void;
onViewDetail: (result: Result) => void;
onDelete: (result: Result) => void;
},
): AdvancedColumn<Result> {
return {
header: '',
label: t('result.columns.actions', 'Actions'),
cell: ({ row }) => {
const r = row.original;
return (
<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>
);
},
};
}

View File

@@ -1,9 +1,7 @@
import { Badge, Box, Button, Group, Text, TextInput } from '@mantine/core';
import { IconEye, IconTrash } from '@tabler/icons-react';
import { Badge, Box, Text } from '@mantine/core';
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';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Result, ResultReviewStatus } from '../../types/result';
export const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
@@ -18,204 +16,63 @@ export const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
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>[] {
showDate: (date: string) => string,
getExamTitle: (id: string) => string,
): AdvancedColumn<Result>[] {
return [
{
key: 'seafarer',
header: t('result.columns.seafarer'),
render: (r) => (
cell: ({ row }) => (
<Text fz="sm" fw={500}>
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
{row.original.seafarer
? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}`
: row.original.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>
cell: ({ row }) => (
<Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text>
),
},
{
key: 'totalScore',
header: t('result.columns.totalScore'),
render: (r) => <Text fz="sm" fw={600}>{r.totalScore}</Text>,
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>,
},
{
key: 'status',
header: t('result.columns.status'),
render: (r) => (
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status]}
color={STATUS_COLOR[row.original.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
/>
}
>
{t(`result.status.${r.status}`)}
{t(`result.status.${row.original.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}`)}
cell: ({ row }) => (
<Badge size="sm" variant="light" color={REVIEW_COLOR[row.original.reviewStatus] ?? 'gray'}>
{t(`result.review.${row.original.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);
}}
/>
),
cell: ({ row }) => <Text fz="sm">{showDate(row.original.createdAt)}</Text>,
},
];
}

View File

@@ -4,10 +4,12 @@ import {
Stack,
Title,
Group,
Table,
Badge,
Modal,
Text,
Paper,
Card,
Loader,
Center,
Alert,
@@ -32,9 +34,10 @@ import {
IconSearch,
IconSend,
} from '@tabler/icons-react';
import { AdvancedTable, notify, BilingualInput } from '@ema-platform/ui';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
import {
useGetResultsQuery,
useLazyGetResultQuery,
@@ -47,15 +50,10 @@ import {
} 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';
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
import { resultActionsColumn, type QcAction } from './actions';
function ResultStat({
label,
@@ -103,9 +101,13 @@ function InfoRow({ label, value }: { label: string; value: string }) {
export function ResultPage() {
const { t, i18n } = useTranslation();
const localized = useLocalized();
const showDate = useDateDisplayer();
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
const { data: examRes } = useGetExamsQuery();
const { data, isLoading, isError, refetch } = useGetResultsQuery();
const { data, isFetching, isError, refetch } = useGetResultsQuery();
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
const exams = examRes?.items ?? [];
@@ -135,7 +137,7 @@ export function ResultPage() {
const [qcRemark, setQcRemark] = useState('');
const [qcAdjustment, setQcAdjustment] = useState(0);
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
const examOptions = exams.map((e) => ({ value: e.id, label: `${localized(e.title)} (${e.date})` }));
const startRecord = () => {
const ex = exams.find((e) => e.id === pickerExamId);
@@ -272,14 +274,24 @@ export function ResultPage() {
notify.success(t('result.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error(t('result.error'));
} catch (e) {
handleError(e);
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
const columns = [
...resultColumns(t, locale, showDate, getExamTitle),
resultActionsColumn(t, {
onQc: openQc,
onViewDetail: viewDetail,
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
}),
];
const page = paginate(filtered);
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
@@ -312,7 +324,7 @@ export function ResultPage() {
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
</SimpleGrid>
<Paper withBorder radius="md">
<Card withBorder padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>{t('result.section')}</Text>
<Group gap="sm" wrap="wrap">
@@ -320,7 +332,7 @@ export function ResultPage() {
placeholder={t('result.search.seafarer')}
leftSection={<IconSearch size={15} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
onChange={(e) => { setSearchQuery(e.currentTarget.value); setPageIndex(0); }}
size="sm"
style={{ width: 240 }}
/>
@@ -328,7 +340,7 @@ export function ResultPage() {
placeholder={t('result.search.filterByExam')}
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
value={examFilter}
onChange={(v) => setExamFilter(v ?? null)}
onChange={(v) => { setExamFilter(v ?? null); setPageIndex(0); }}
size="sm"
style={{ width: 280 }}
clearable
@@ -337,18 +349,19 @@ export function ResultPage() {
</Group>
<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')}
columns={columns}
data={page.rows}
tableName={t('result.title')}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
</Paper>
</Card>
<Modal
opened={detailOpened}
@@ -372,7 +385,7 @@ export function ResultPage() {
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<InfoRow label={t('result.detail.fullName')} value={detailResult.profile ? `${detailResult.profile.firstName} ${detailResult.profile.middleName ?? ''} ${detailResult.profile.lastName}` : detailResult.seafarerId} />
<InfoRow label={t('result.detail.gender')} value={detailResult.profile?.gender ?? '—'} />
<InfoRow label={t('result.detail.dateOfBirth')} value={detailResult.profile?.dob ? new Date(detailResult.profile.dob).toLocaleDateString() : '—'} />
<InfoRow label={t('result.detail.dateOfBirth')} value={showDate(detailResult.profile?.dob)} />
<InfoRow label={t('result.detail.maritalStatus')} value={detailResult.profile?.maritalStatus ?? '—'} />
</SimpleGrid>
</Paper>
@@ -390,7 +403,7 @@ export function ResultPage() {
<InfoRow label={t('result.detail.examTitle')} value={detailResult.exam.title?.[locale] ?? '—'} />
<InfoRow label={t('result.detail.type')} value={detailResult.exam.type ?? '—'} />
<InfoRow label={t('result.detail.venue')} value={detailResult.exam.venue ?? '—'} />
<InfoRow label={t('result.detail.date')} value={detailResult.exam.date ? new Date(detailResult.exam.date).toLocaleDateString() : '—'} />
<InfoRow label={t('result.detail.date')} value={showDate(detailResult.exam.date)} />
<InfoRow label={t('result.detail.passMark')} value={String(detailResult.exam.cuttingPoint ?? 0)} />
</SimpleGrid>
</Paper>
@@ -431,19 +444,62 @@ export function ResultPage() {
{detailBreakdowns.length > 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{t('result.detail.scoreBreakdown')}</Text>
<AdvancedTable
columns={resultBreakdownColumns(t, locale, {
breakdowns: detailBreakdowns,
questions: exams.find((e) => e.id === detailResult.examId)?.questions,
onChange: setDetailBreakdowns,
})}
data={detailBreakdowns}
rowKey={(b) => b.questionId}
/>
<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>
</>
)}
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<Button
onClick={handleDetailSave}
@@ -453,7 +509,7 @@ export function ResultPage() {
>
{t('result.save')}
</Button>
</Group>
</ModalFooter>
</Stack>
) : (
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
@@ -509,10 +565,10 @@ export function ResultPage() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
<Text mb="md">{t('result.deleteConfirmText')}</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
</Group>
</ModalFooter>
</Modal>
{/* Choose exam, then record */}
@@ -529,10 +585,10 @@ export function ResultPage() {
searchable
required
/>
<Group justify="flex-end">
<ModalFooter>
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
</Group>
</ModalFooter>
</Stack>
</Modal>