mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 15:25:47 +00:00
feat: added exam modules
This commit is contained in:
45
apps/backoffice/src/app/features/result/api/result-api.ts
Normal file
45
apps/backoffice/src/app/features/result/api/result-api.ts
Normal 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;
|
||||
194
apps/backoffice/src/app/features/result/pages/ResultPage.tsx
Normal file
194
apps/backoffice/src/app/features/result/pages/ResultPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
apps/backoffice/src/app/features/result/types/result.ts
Normal file
42
apps/backoffice/src/app/features/result/types/result.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user