feat: added exam modules

This commit is contained in:
mengstabketemaw
2026-06-26 12:10:38 +03:00
parent ba4cb63423
commit d7420d2de6
16 changed files with 1959 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import { baseApi } from '@ema-platform/api';
import type {
Result,
ListResponse,
CreateResultPayload,
UpdateResultPayload,
} from '../types/result';
const resultApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getResults: builder.query<ListResponse<Result>, void>({
query: () => '/results',
providesTags: ['Api'],
}),
getResult: builder.query<Result, string>({
query: (id) => `/results/${id}`,
providesTags: ['Api'],
}),
createResult: builder.mutation<Result, CreateResultPayload>({
query: (body) => ({ url: '/results', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateResult: builder.mutation<Result, UpdateResultPayload>({
query: ({ id, ...body }) => ({
url: `/results/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteResult: builder.mutation<void, string>({
query: (id) => ({ url: `/results/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetResultsQuery,
useGetResultQuery,
useCreateResultMutation,
useUpdateResultMutation,
useDeleteResultMutation,
} = resultApi;

View File

@@ -0,0 +1,194 @@
import { useState } from 'react';
import {
Stack,
Title,
Group,
Table,
Badge,
Modal,
Text,
Paper,
Loader,
Center,
Alert,
Select,
SimpleGrid,
Divider,
Button,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconInfoCircle, IconEye } from '@tabler/icons-react';
import { useGetResultsQuery } from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
import type { Result } from '../types/result';
const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
FAILED: 'red',
};
function ResultDetail({ result }: { result: Result }) {
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>
<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>
)}
{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>
</>
)}
</Stack>
);
}
export function ResultPage() {
const { data: examRes } = useGetExamsQuery();
const { data, isLoading, isError } = useGetResultsQuery();
const exams = examRes?.items ?? [];
const results = data?.items ?? [];
const [examFilter, setExamFilter] = useState<string | null>(null);
const [selectedResult, setSelectedResult] = useState<Result | null>(null);
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
const filtered = results.filter((r) => !examFilter || r.examId === examFilter);
const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.en ?? '-';
const viewDetail = (result: Result) => {
setSelectedResult(result);
openDetail();
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading results" />;
return (
<Stack gap="lg">
<div>
<Title order={2}>Exam Results</Title>
<Text fz="sm" c="dimmed">View seafarer examination results and score breakdowns</Text>
</div>
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Results</Text>
<Select
placeholder="Filter by exam"
data={[{ value: '', label: 'All Exams' }, ...examOptions]}
value={examFilter}
onChange={(v) => setExamFilter(v ?? null)}
size="sm"
style={{ width: 320 }}
clearable
/>
</Group>
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>Seafarer</Table.Th>
<Table.Th>Exam</Table.Th>
<Table.Th>Total Score</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>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.en : 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]}>{r.status}</Badge>
</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>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="xl">No results found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<Modal opened={detailOpened} onClose={closeDetail} title="Result Detail" size="lg" radius="lg">
{selectedResult && <ResultDetail result={selectedResult} />}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,42 @@
export type ExamResultStatus = 'PASSED' | 'FAILED';
export interface ResultBreakdown {
questionId: string;
score: number;
remark?: string;
}
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 } };
resultBreakdowns: ResultBreakdown[];
totalScore: number;
remark: { en: string; am: string } | null;
status: ExamResultStatus;
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
total: number;
items: T[];
}
export interface CreateResultPayload {
seafarerId: string;
examId: string;
resultBreakdowns: ResultBreakdown[];
totalScore?: number;
remark?: { en: string; am: string };
}
export interface UpdateResultPayload {
id: string;
resultBreakdowns?: ResultBreakdown[];
totalScore?: number;
remark?: { en: string; am: string };
status?: ExamResultStatus;
}