import { type StatusTone } from '@ema-platform/shared'; 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, Tooltip, 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, } from '@tabler/icons-react'; import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation, useSelectRandomQuestionsMutation, useGetExamRegistrationsQuery, } 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 { ExamCandidatesPanel } from '../components/ExamCandidatesPanel'; import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel'; import { PageLoader } from '@ema-platform/ui'; import type { ExamStatus, QuestionBrief } from '../types/exam'; const STATUS_TONE: Record = { PENDING: 'neutral', ACTIVE: 'info', COMPLETED: 'success', CANCELLED: 'danger', POSTPONED: 'pending', PUBLISHED: 'success', }; const FORM_LABEL: Record = { ESSAY: "Essay", CHOICE: "Choice", BOTH: "Both", }; 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 [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation(); const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id }); // The backend locks the paper the moment the first candidate registers // (ExamService.assertPaperEditable) — every candidate must sit the same // paper. Same query ExamCandidatesPanel already runs, so RTK Query serves // it from cache rather than issuing a second request. const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id }); const paperLocked = (registrations?.length ?? 0) > 0; const { data: qRes } = useGetQuestionsQuery(); const { data: certRes } = useGetCertificationsQuery(); const allQuestions = qRes?.items ?? []; const certifications = certRes?.items ?? []; // Only approved bank items may go on a paper (US-EXAM-003), so the picker // must not offer drafts or retired questions either. // // Filters on exam.form alone, not administrationMethod: the backend no // longer restricts ONLINE to CHOICE (ExamService no longer has an // assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of // truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH // describes a mixed paper — a question itself is never "BOTH" (see // QuestionForm), so an equality check against it would match nothing and // silently offer zero questions; skipped the same way the backend's own // random draw does (ExamService.selectRandomQuestions). const eligibleQuestions = useMemo(() => { if (!exam) return []; return allQuestions .filter( (q) => q.certificationId === exam.certificationId && (exam.form === 'BOTH' || q.form === exam.form) && q.status === 'APPROVED', ) .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(); }; /** * The draw happens on the server (US-EXAM-005): it picks only approved * items for this subject and writes the paper in one call, so the pool is * never shipped to the browser and cannot be reshuffled until it flatters. */ const handleRandomSelect = async () => { try { const updated = await selectRandom({ examId: exam.id, count: randomCount }).unwrap(); setDraftQuestions(updated.questions ?? []); notify.success(t('exam.randomSelected', { count: (updated.questions ?? []).length })); closeAssign(); } catch (error) { const key = extractErrorMessage(error, t('exam.randomError')); notify.error( key === 'paper_locked_after_registration' ? t('exam.paperLockedHint', { count: registrations?.length ?? 0 }) : key.startsWith('insufficient_approved_questions') ? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})` : key.startsWith('paper_cannot_reach_cutting_point') ? t('exam.cannotReachCuttingPoint', { max: key.split(':')[1]?.split('/')[0] ?? '', cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '', }) : key, ); } }; const handleAssign = async () => { try { const questionIds = draftQuestions.map((q) => q.id); await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap(); notify.success('Questions assigned'); closeAssign(); } catch (error) { const key = extractErrorMessage(error, 'Failed to assign questions'); notify.error( key === 'paper_locked_after_registration' ? t('exam.paperLockedHint', { count: registrations?.length ?? 0 }) : key.startsWith('question_not_approved') ? t('question.qc.onlyApprovedUsable') : key.startsWith('paper_cannot_reach_cutting_point') ? t('exam.cannotReachCuttingPoint', { max: key.split(':')[1]?.split('/')[0] ?? '', cuttingPoint: key.split(':')[1]?.split('/')[1] ?? '', }) : key, ); } }; const handlePrint = async () => { // The reachable max depends on the evaluation method, not the raw point // sum — mirrors RecordResultModal's grading math so "can this paper pass" // means the same thing here as it does at marking time. Cutting point can // be raised after the paper was assembled (edit modal, no re-check on // save), so this still needs to run even though assignment now enforces // it too. const questions = exam.questions ?? []; const total = questions.reduce((s, q) => s + Number(q.points), 0); const reachableMax = exam.evaluationMethod === 'AVERAGE' ? questions.length ? total / questions.length : 0 : exam.evaluationMethod === 'PERCENTAGE' ? 100 : total; if (reachableMax < Number(exam.cuttingPoint)) { notify.error( `This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass 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" ? q.options && q.options.length ? q.options .slice() .sort((a, b) => a.order - b.order) .map( (o, oi) => `

${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}

`, ) .join("") // No options on record (legacy question, or options relation // wasn't loaded) — fall back to blank lines rather than // printing nothing. : ["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} `); 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 */} {/* Exam Info */} {t("exam.detail.title")} {(exam.direction?.en || exam.direction?.am) && ( <> )} {/* Questions */} {t("exam.detail.questionsSection", { pts: totalPoints })} {paperLocked && ( {t("exam.paperLocked")} )} {/* Wrapped: a disabled Mantine Button fires no pointer events, so the tooltip needs an enabled element to hang off. */} {(exam.questions ?? []).length === 0 ? ( } > {paperLocked ? t("exam.paperLockedHint", { count: registrations?.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]} ))} )} {/* Exam-day operations: who sat the paper, and what went wrong */} {/* Question assignment modal */} {exam.selectionMethod === "MANUAL" ? ( <> ) : ( <> {t('exam.randomHintServer')} setRandomCount(Number(v))} min={1} size="xs" style={{ width: 80 }} /> )}
); }