From 2b2ae71a8fcdc9febf40ff449fd871aaf52f8698 Mon Sep 17 00:00:00 2001 From: mengstabketemaw Date: Sat, 27 Jun 2026 11:53:15 +0300 Subject: [PATCH] feat: enhance exam result details and display comprehensive report information commit --- .../features/exam/pages/ExamDetailPage.tsx | 19 +- .../src/app/features/result/api/result-api.ts | 3 +- .../app/features/result/pages/ResultPage.tsx | 288 ++++++++++++++---- .../src/app/features/result/types/result.ts | 38 ++- 4 files changed, 268 insertions(+), 80 deletions(-) diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index 8d4c7bb31..52d904e9c 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -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 = { @@ -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); diff --git a/apps/backoffice/src/app/features/result/api/result-api.ts b/apps/backoffice/src/app/features/result/api/result-api.ts index 6259d29f7..eef83e031 100644 --- a/apps/backoffice/src/app/features/result/api/result-api.ts +++ b/apps/backoffice/src/app/features/result/api/result-api.ts @@ -13,7 +13,7 @@ const resultApi = baseApi.injectEndpoints({ providesTags: ['Api'], }), getResult: builder.query({ - query: (id) => `/results/${id}`, + query: (id) => `/results/${id}?i=exam,profile`, providesTags: ['Api'], }), createResult: builder.mutation({ @@ -39,6 +39,7 @@ const resultApi = baseApi.injectEndpoints({ export const { useGetResultsQuery, useGetResultQuery, + useLazyGetResultQuery, useCreateResultMutation, useUpdateResultMutation, useDeleteResultMutation, diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx index 45fc590af..b527d8a1b 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage.tsx @@ -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 = { FAILED: 'red', }; +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value || '—'} +
+ ); +} + 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 ( - -
- Seafarer - - {result.seafarer - ? `${result.seafarer.firstName} ${result.seafarer.middleName ?? ''} ${result.seafarer.lastName}` - : result.seafarerId} - -
-
- Exam - {result.exam?.title?.en ?? result.examId} -
-
- Total Score - {result.totalScore} -
-
- Status - {result.status} -
-
+ {/* Seafarer Profile */} + + + + + + Seafarer Profile + + + + + + + + - - - Score Breakdown - {result.resultBreakdowns.length === 0 ? ( - No breakdown data - ) : ( - - - - Question ID - Score - Remark - - - - {result.resultBreakdowns.map((b, i) => ( - - {b.questionId.slice(0, 8)} - {b.score} - {b.remark ?? '—'} - - ))} - -
+ {/* Exam Details */} + {exam && ( + + + + + + Exam Details + + + + + + + + + + + + + + + + {exam.direction?.en && ( + <> + + + + )} + )} + {/* Score Breakdown */} + + + + + + Score Breakdown + + {result.resultBreakdowns.length === 0 ? ( + No breakdown data + ) : ( + + + + # + Question + Form + Max + Score + Remark + + + + {result.resultBreakdowns.map((b, i) => { + const q = exam?.questions?.find((eq) => eq.id === b.questionId); + return ( + + {i + 1} + + + {q?.title?.en ?? b.questionId.slice(0, 8)} + + + + {q?.form && ( + + {q.form} + + )} + + {q?.points ?? '—'} + {b.score} + {b.remark || '—'} + + ); + })} + +
+ )} + + + + + + + +
+ Status + + {passed ? ( + <>PASSED + ) : ( + <>FAILED + )} + +
+
+
+
+ {result.remark && ( - <> - -
- Officer Remark - {result.remark.en || result.remark.am} -
- + + + )}
); @@ -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(null); - const [selectedResult, setSelectedResult] = useState(null); const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false); + const [deleteTarget, setDeleteTarget] = useState(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
; if (isError) return } color="red" title="Error loading results" />; @@ -164,14 +287,25 @@ export function ResultPage() { {new Date(r.createdAt).toLocaleDateString()} - + + + + ))} @@ -186,8 +320,28 @@ export function ResultPage() { - - {selectedResult && } + + {isDetailLoading ? ( +
+ ) : detailResult ? ( + + ) : ( + No data available + )} +
+ + + Are you sure you want to delete this result? + + + + ); diff --git a/apps/backoffice/src/app/features/result/types/result.ts b/apps/backoffice/src/app/features/result/types/result.ts index cc84add0f..a52692d91 100644 --- a/apps/backoffice/src/app/features/result/types/result.ts +++ b/apps/backoffice/src/app/features/result/types/result.ts @@ -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;