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