feat: enhance exam result details and display comprehensive report information commit

This commit is contained in:
mengstabketemaw
2026-06-27 11:53:15 +03:00
parent 0b9322f016
commit 2b2ae71a8f
4 changed files with 268 additions and 80 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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;