mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 15:25:47 +00:00
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:
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user