import { useState, useEffect, useRef, useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Stack, Title, Group, Text, Paper, Badge, SimpleGrid, Divider, Button, ActionIcon, Alert, Loader, Center, Modal, Table, Select, NumberInput, TextInput, ThemeIcon, Box, rem, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { IconArrowLeft, IconPrinter, IconPlus, IconInfoCircle, IconCertificate, IconCalendar, IconMapPin, IconClock, IconScoreboard, IconUser, IconCheck, IconX, IconDetails } from "@tabler/icons-react"; import { notify, useErrorHandler } from "@ema-platform/ui"; import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation, } from "../api/exam-api"; import { useGetQuestionsQuery } from "../../question/api/question-api"; import { useGetCertificationsQuery } from "../../certification/api/certification-api"; import { QuestionAssigner } from "../components/QuestionAssigner"; import { RecordResultModal } from "../../result/components/RecordResultModal"; import type { ExamStatus, QuestionBrief, actionTypes } from "../types/exam"; const STATUS_COLOR: Record = { PENDING: "gray", ACTIVE: "blue", COMPLETED: "teal", CANCELLED: "red", POSTPONED: "orange", PUBLISHED: "green", }; const FORM_LABEL: Record = { ESSAY: "Essay", CHOICE: "Choice" }; const TYPE_LABEL: Record = { WRITTEN: "Written", ORAL: "Oral" }; const ADMIN_LABEL: Record = { OFFLINE: "Offline", ONLINE: "Online", }; const EVAL_LABEL: Record = { SUM: "Sum", AVERAGE: "Average", PERCENTAGE: "Percentage", }; function InfoRow({ label, value }: { label: string; value: string }) { return (
{label} {value || "—"}
); } export function ExamDetailPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as "en" | "am"; const { handleError } = useErrorHandler(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const printRef = useRef(null); const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false); const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false); const [draftQuestions, setDraftQuestions] = useState([]); const [randomCount, setRandomCount] = useState(5); const [updateExam] = useUpdateExamMutation(); const [assignQuestions, { isLoading: isAssigning }] = useAssignQuestionsMutation(); const { data: exam, isLoading, isError, } = useGetExamQuery(id ?? "", { skip: !id }); const { data: qRes } = useGetQuestionsQuery(); const { data: certRes } = useGetCertificationsQuery(); const allQuestions = qRes?.items ?? []; const certifications = certRes?.items ?? []; const [whatAction, setWhatAction] = useState(); const eligibleQuestions = useMemo(() => { if (!exam) return []; return allQuestions .filter( (q) => q.certificationId === exam.certificationId && q.form === exam.form, ) .map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points, })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [allQuestions, exam?.certificationId, exam?.form]); if (isLoading) return (
); if (isError || !exam) { return ( }> {t("exam.notFound")} ); } const openAssignModal = () => { setDraftQuestions(exam.questions ?? []); setRandomCount(5); openAssign(); }; const handleRandomSelect = () => { const assignedIds = new Set(draftQuestions.map((q) => q.id)); const currentTotal = draftQuestions.reduce( (s, q) => s + Number(q.points), 0, ); const cuttingPoint = Number(exam.cuttingPoint); const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id)); if (eligible.length === 0) { notify.error("No eligible questions available for random selection"); return; } const maxPossible = currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0); if (maxPossible < cuttingPoint) { notify.error( `Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`, ); return; } const shuffled = [...eligible].sort(() => Math.random() - 0.5); const targetCount = Math.min(randomCount, shuffled.length); const picked = shuffled.slice(0, targetCount); let pickedTotal = picked.reduce((s, q) => s + Number(q.points), 0); if (currentTotal + pickedTotal < cuttingPoint) { const remaining = shuffled.slice(targetCount); for (const q of remaining) { if (currentTotal + pickedTotal >= cuttingPoint) break; picked.push(q); pickedTotal += q.points; } } const msg = picked.length > targetCount ? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)` : `Randomly selected ${picked.length} questions`; setDraftQuestions([...draftQuestions, ...picked]); notify.info(msg); }; const handleAssign = async () => { try { const questionIds = draftQuestions.map((q) => q.id); if (whatAction === "add") { await assignQuestions({ examId: exam.id, questionIds, remark: undefined, }).unwrap(); notify.success("Questions assigned"); closeAssign(); } else if (whatAction === "remove") { // await removeQuestions({ // examId: exam.id, // questionIds, // remark: undefined, // }); // notify.success("Questions removed"); // closeAssign(); notify.warning("no action yet"); } } catch (e) { handleError(e); } }; const handlePrint = async () => { const total = (exam.questions ?? []).reduce( (s, q) => s + Number(q.points), 0, ); if (total < Number(exam.cuttingPoint)) { notify.error( `Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`, ); return; } const printWindow = window.open("", "_blank"); if (!printWindow) return; let logoBase64 = ""; try { const resp = await fetch("/ema-logo.png"); const blob = await resp.blob(); logoBase64 = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); } catch { /* logo not available */ } const qMap = new Map(allQuestions.map((qq) => [qq.id, qq])); const qHtml = (exam.questions ?? []) .map((q, i) => { const full = qMap.get(q.id); const titleStr = q.title[locale] || q.title.en; const descStr = full?.description?.[locale] || full?.description?.en || ""; return `

Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})

${titleStr}

${descStr ? `

${descStr}

` : ""} ${q.form === "ESSAY" ? '
'.repeat(3) : ""} ${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `

${l}

`).join("") : ""}
`; }) .join(""); printWindow.document.write(` ${exam.title[locale] || exam.title.en}
${logoBase64 ? `` : ""}

${exam.title[locale] || exam.title.en}

Date: ${exam.date} | Venue: ${exam.venue}

Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : "N/A"}

Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}

${exam.direction?.[locale] ? `
Directions: ${exam.direction[locale]}
` : ""} ${qHtml}
Generated by EMA — Ethiopian Maritime Authority
`); printWindow.document.close(); printWindow.focus(); setTimeout(() => printWindow.print(), 500); }; const totalPoints = (exam.questions ?? []).reduce( (s, q) => s + Number(q.points), 0, ); const certName = exam.certification?.name?.[locale] ?? certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ?? "—"; return ( {/* Header */} navigate("/exams")} >
{exam.title[locale]}
{/* Status badge */} {t(`exam.status.${exam.status}`)} {/* Exam Info */} {t("exam.detail.title")} {(exam.direction?.en || exam.direction?.am) && ( <> )} {/* Questions */} {t("exam.detail.questionsSection", { pts: totalPoints })} {(exam.questions ?? []).length === 0 ? ( }> {t("exam.noQuestionsAssigned")} ) : ( {(exam.questions ?? []).map((q, i) => ( {t("exam.detail.questionLabel")} {i + 1} {t(`exam.formType.${q.form}`)} {q.points} pts {q.title[locale]} ))} )} {/* Question assignment modal */} {exam.selectionMethod === "MANUAL" ? ( <> ) : ( <> {t("exam.assigner.randomHint", { total: eligibleQuestions.length, pts: exam.cuttingPoint, })} setRandomCount(Number(v))} min={1} max={eligibleQuestions.length} size="xs" style={{ width: 80 }} /> )}
); }