mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: enhance exam result details and display comprehensive report information commit
This commit is contained in:
@@ -39,8 +39,9 @@ import {
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useApiQuery, useApiMutation } from '@ema-platform/api';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import { useGetExamQuery, useUpdateExamMutation } from '../api/exam-api';
|
||||
import { useCreateResultMutation } from '../../result/api/result-api';
|
||||
import type { Exam, ExamStatus } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -80,7 +81,7 @@ function RecordResultModal({
|
||||
url: '/profiles',
|
||||
params: { q: 'w=type:=:SEAFARER' },
|
||||
});
|
||||
const [createResult, { isLoading: isSaving }] = useApiMutation();
|
||||
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
|
||||
|
||||
const seafarers = profilesRes?.items ?? [];
|
||||
const questions = exam.questions ?? [];
|
||||
@@ -113,15 +114,11 @@ function RecordResultModal({
|
||||
remark: '',
|
||||
}));
|
||||
await createResult({
|
||||
url: '/results',
|
||||
method: 'POST',
|
||||
body: {
|
||||
seafarerId: selectedSeafarerId,
|
||||
examId: exam.id,
|
||||
resultBreakdowns: breakdowns,
|
||||
totalScore,
|
||||
remark: remark ? { en: remark, am: '' } : undefined,
|
||||
},
|
||||
seafarerId: selectedSeafarerId,
|
||||
examId: exam.id,
|
||||
resultBreakdowns: breakdowns,
|
||||
totalScore,
|
||||
remark: remark ? { en: remark, am: '' } : undefined,
|
||||
}).unwrap();
|
||||
notify.success(`Result recorded — ${passed ? 'PASSED' : 'FAILED'} (${totalScore}/${exam.cuttingPoint})`);
|
||||
setSelectedSeafarerId(null);
|
||||
|
||||
@@ -13,7 +13,7 @@ const resultApi = baseApi.injectEndpoints({
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
getResult: builder.query<Result, string>({
|
||||
query: (id) => `/results/${id}`,
|
||||
query: (id) => `/results/${id}?i=exam,profile`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
createResult: builder.mutation<Result, CreateResultPayload>({
|
||||
@@ -39,6 +39,7 @@ const resultApi = baseApi.injectEndpoints({
|
||||
export const {
|
||||
useGetResultsQuery,
|
||||
useGetResultQuery,
|
||||
useLazyGetResultQuery,
|
||||
useCreateResultMutation,
|
||||
useUpdateResultMutation,
|
||||
useDeleteResultMutation,
|
||||
|
||||
@@ -15,10 +15,25 @@ import {
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconInfoCircle, IconEye } from '@tabler/icons-react';
|
||||
import { useGetResultsQuery } from '../api/result-api';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconEye,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
IconCertificate,
|
||||
IconScoreboard,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconMapPin,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation } from '../api/result-api';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
import type { Result } from '../types/result';
|
||||
|
||||
@@ -27,66 +42,158 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
FAILED: 'red',
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultDetail({ result }: { result: Result }) {
|
||||
const exam = result.exam;
|
||||
const profile = result.profile;
|
||||
const totalScore = Number(result.totalScore);
|
||||
const cuttingPoint = exam?.cuttingPoint ? Number(exam.cuttingPoint) : 0;
|
||||
const passed = totalScore >= cuttingPoint;
|
||||
|
||||
const formatTime = (t: { days?: number; hours?: number; minutes?: number } | null | undefined) => {
|
||||
if (!t) return '—';
|
||||
const parts: string[] = [];
|
||||
if (t.days) parts.push(`${t.days}d`);
|
||||
if (t.hours) parts.push(`${t.hours}h`);
|
||||
if (t.minutes) parts.push(`${t.minutes}m`);
|
||||
return parts.join(' ') || '—';
|
||||
};
|
||||
|
||||
const formatDate = (d: string | undefined) => d ? new Date(d).toLocaleDateString() : '—';
|
||||
const formatDob = (d: string | undefined) => d ? new Date(d).toLocaleDateString() : '—';
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Seafarer</Text>
|
||||
<Text fz="sm" fw={500}>
|
||||
{result.seafarer
|
||||
? `${result.seafarer.firstName} ${result.seafarer.middleName ?? ''} ${result.seafarer.lastName}`
|
||||
: result.seafarerId}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Exam</Text>
|
||||
<Text fz="sm">{result.exam?.title?.en ?? result.examId}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Total Score</Text>
|
||||
<Text fz="sm" fw={700}>{result.totalScore}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[result.status]}>{result.status}</Badge>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
{/* Seafarer Profile */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group gap="sm" mb="sm">
|
||||
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
|
||||
<IconUser size={14} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} fz="sm">Seafarer Profile</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
<InfoRow label="Full Name" value={profile ? `${profile.firstName} ${profile.middleName ?? ''} ${profile.lastName}` : result.seafarerId} />
|
||||
<InfoRow label="Gender" value={profile?.gender ?? '—'} />
|
||||
<InfoRow label="Date of Birth" value={formatDob(profile?.dob)} />
|
||||
<InfoRow label="Marital Status" value={profile?.maritalStatus ?? '—'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fw={600} fz="sm">Score Breakdown</Text>
|
||||
{result.resultBreakdowns.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">No breakdown data</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Question ID</Table.Th>
|
||||
<Table.Th>Score</Table.Th>
|
||||
<Table.Th>Remark</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{result.resultBreakdowns.map((b, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td><Text fz="xs">{b.questionId.slice(0, 8)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{b.score}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{b.remark ?? '—'}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{/* Exam Details */}
|
||||
{exam && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group gap="sm" mb="sm">
|
||||
<ThemeIcon size="sm" variant="light" color="violet" radius="xl">
|
||||
<IconCertificate size={14} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} fz="sm">Exam Details</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
<InfoRow label="Exam Title" value={exam.title?.en ?? '—'} />
|
||||
<InfoRow label="Title (Amharic)" value={exam.title?.am ?? '—'} />
|
||||
<InfoRow label="Type" value={exam.type ?? '—'} />
|
||||
<InfoRow label="Form" value={exam.form ?? '—'} />
|
||||
<InfoRow label="Venue" value={exam.venue ?? '—'} />
|
||||
<InfoRow label="Date" value={formatDate(exam.date)} />
|
||||
<InfoRow label="Pass Mark" value={String(cuttingPoint)} />
|
||||
<InfoRow label="Evaluation" value={exam.evaluationMethod ?? '—'} />
|
||||
<InfoRow label="Selection" value={exam.selectionMethod ?? '—'} />
|
||||
<InfoRow label="Time Allowed" value={formatTime(exam.givenTime)} />
|
||||
<InfoRow label="Status" value={exam.status ?? '—'} />
|
||||
<InfoRow label="Questions" value={String(exam.questions?.length ?? 0)} />
|
||||
</SimpleGrid>
|
||||
{exam.direction?.en && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<InfoRow label="Directions" value={exam.direction.en} />
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group gap="sm" mb="sm">
|
||||
<ThemeIcon size="sm" variant="light" color="orange" radius="xl">
|
||||
<IconScoreboard size={14} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} fz="sm">Score Breakdown</Text>
|
||||
</Group>
|
||||
{result.resultBreakdowns.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">No breakdown data</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>#</Table.Th>
|
||||
<Table.Th>Question</Table.Th>
|
||||
<Table.Th>Form</Table.Th>
|
||||
<Table.Th>Max</Table.Th>
|
||||
<Table.Th>Score</Table.Th>
|
||||
<Table.Th>Remark</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{result.resultBreakdowns.map((b, i) => {
|
||||
const q = exam?.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={220}>
|
||||
{q?.title?.en ?? b.questionId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{q?.form && (
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{q.form}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{b.score}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{b.remark || '—'}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Paper withBorder p="sm" radius="md" bg="gray.0">
|
||||
<SimpleGrid cols={3} spacing="sm">
|
||||
<InfoRow label="Total Score" value={`${totalScore} / ${cuttingPoint}`} />
|
||||
<InfoRow label="Pass Mark" value={String(cuttingPoint)} />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
||||
<Group gap={4} mt={2}>
|
||||
{passed ? (
|
||||
<><IconCheck size={14} color="var(--mantine-color-teal-6)" /><Text fz="sm" fw={700} c="teal">PASSED</Text></>
|
||||
) : (
|
||||
<><IconX size={14} color="var(--mantine-color-red-6)" /><Text fz="sm" fw={700} c="red">FAILED</Text></>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Paper>
|
||||
|
||||
{result.remark && (
|
||||
<>
|
||||
<Divider />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Officer Remark</Text>
|
||||
<Text fz="sm">{result.remark.en || result.remark.am}</Text>
|
||||
</div>
|
||||
</>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<InfoRow label="Officer Remark" value={result.remark.en || result.remark.am || '—'} />
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -95,13 +202,17 @@ function ResultDetail({ result }: { result: Result }) {
|
||||
export function ResultPage() {
|
||||
const { data: examRes } = useGetExamsQuery();
|
||||
const { data, isLoading, isError } = useGetResultsQuery();
|
||||
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
||||
|
||||
const exams = examRes?.items ?? [];
|
||||
const results = data?.items ?? [];
|
||||
|
||||
const [deleteResult] = useDeleteResultMutation();
|
||||
|
||||
const [examFilter, setExamFilter] = useState<string | null>(null);
|
||||
const [selectedResult, setSelectedResult] = useState<Result | null>(null);
|
||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
|
||||
|
||||
@@ -110,10 +221,22 @@ export function ResultPage() {
|
||||
const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.en ?? '-';
|
||||
|
||||
const viewDetail = (result: Result) => {
|
||||
setSelectedResult(result);
|
||||
fetchDetail(result.id);
|
||||
openDetail();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteResult(deleteTarget.id).unwrap();
|
||||
notify.success('Result deleted');
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error('Failed to delete result');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading results" />;
|
||||
|
||||
@@ -164,14 +287,25 @@ export function ResultPage() {
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconEye size={13} />}
|
||||
onClick={() => viewDetail(r)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconEye size={13} />}
|
||||
onClick={() => viewDetail(r)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => { setDeleteTarget(r); openDelete(); }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -186,8 +320,28 @@ export function ResultPage() {
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={detailOpened} onClose={closeDetail} title="Result Detail" size="lg" radius="lg">
|
||||
{selectedResult && <ResultDetail result={selectedResult} />}
|
||||
<Modal
|
||||
opened={detailOpened}
|
||||
onClose={closeDetail}
|
||||
title="Result Detail"
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
{isDetailLoading ? (
|
||||
<Center py="xl"><Loader /></Center>
|
||||
) : detailResult ? (
|
||||
<ResultDetail result={detailResult} />
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="xl">No data available</Text>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Result" size="sm">
|
||||
<Text mb="md">Are you sure you want to delete this result?</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDelete} size="sm">Cancel</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">Delete</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { LocalePair } from '../../certification/types/certification';
|
||||
import type { EstimatedTime } from '../../question/types/question';
|
||||
import type { QuestionForm } from '../../question/types/question';
|
||||
export type { QuestionForm };
|
||||
|
||||
export type ExamResultStatus = 'PASSED' | 'FAILED';
|
||||
|
||||
export interface ResultBreakdown {
|
||||
@@ -6,12 +11,43 @@ export interface ResultBreakdown {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
firstName: string;
|
||||
middleName?: string;
|
||||
lastName: string;
|
||||
gender?: string;
|
||||
dob?: string;
|
||||
maritalStatus?: string;
|
||||
type?: string;
|
||||
isComplete?: boolean;
|
||||
}
|
||||
|
||||
export interface FullExam {
|
||||
id: string;
|
||||
certificationId?: string;
|
||||
title: LocalePair;
|
||||
direction?: LocalePair | null;
|
||||
date?: string;
|
||||
givenTime?: EstimatedTime | null;
|
||||
type?: string;
|
||||
form?: QuestionForm;
|
||||
venue?: string;
|
||||
administrationMethod?: string;
|
||||
evaluationMethod?: string;
|
||||
selectionMethod?: string;
|
||||
cuttingPoint?: number;
|
||||
status?: string;
|
||||
questions?: { id: string; title: LocalePair; form: QuestionForm; points: number }[];
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
seafarer?: { id: string; firstName: string; middleName?: string; lastName: string };
|
||||
examId: string;
|
||||
exam?: { id: string; title: { en: string; am: string } };
|
||||
exam?: FullExam;
|
||||
profile?: Profile;
|
||||
resultBreakdowns: ResultBreakdown[];
|
||||
totalScore: number;
|
||||
remark: { en: string; am: string } | null;
|
||||
|
||||
Reference in New Issue
Block a user