Files
emaui/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx

567 lines
20 KiB
TypeScript

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,
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,
} 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<string, StatusTone> = {
PENDING: 'neutral',
ACTIVE: 'info',
COMPLETED: 'success',
CANCELLED: 'danger',
POSTPONED: 'pending',
PUBLISHED: 'success',
};
const FORM_LABEL: Record<string, string> = {
ESSAY: "Essay",
CHOICE: "Choice",
BOTH: "Both",
};
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
const ADMIN_LABEL: Record<string, string> = {
OFFLINE: "Offline",
ONLINE: "Online",
};
const EVAL_LABEL: Record<string, string> = {
SUM: "Sum",
AVERAGE: "Average",
PERCENTAGE: "Percentage",
};
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text fz="sm" fw={500}>
{value || "—"}
</Text>
</div>
);
}
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<HTMLDivElement>(null);
const [recordOpened, { open: openRecord, close: closeRecord }] =
useDisclosure(false);
const [assignOpened, { open: openAssign, close: closeAssign }] =
useDisclosure(false);
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
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 });
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.
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
.filter(
(q) =>
q.certificationId === exam.certificationId &&
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 <PageLoader label="Loading Exam Details…" height={400} />;
if (isError || !exam) {
return (
<Stack gap="md">
<Button
variant="subtle"
leftSection={<IconArrowLeft size={15} />}
w="fit-content"
onClick={() => navigate("/exams")}
>
{t("exam.backToExams")}
</Button>
<Alert color="red" icon={<IconInfoCircle size={17} />}>
{t("exam.notFound")}
</Alert>
</Stack>
);
}
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.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.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<string>((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 `
<div style="margin-bottom: 24px; page-break-inside: avoid;">
<p style="font-weight: 700; margin-bottom: 4px; font-size: 13px;">Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})</p>
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
${
q.form === "CHOICE"
? q.options && q.options.length
? q.options
.slice()
.sort((a, b) => a.order - b.order)
.map(
(o, oi) =>
`<p style="margin: 4px 0; font-size: 13px;">${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}</p>`,
)
.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) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("")
: ""
}
</div>`;
})
.join("");
printWindow.document.write(`
<html><head><title>${exam.title[locale] || exam.title.en}</title>
<style>
body { font-family: sans-serif; padding: 40px; max-width: 800px; margin: auto; }
.header { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #333; padding-bottom: 16px; }
.header-logo { max-width: 80px; margin-bottom: 8px; }
.header h1 { font-size: 20px; margin: 0 0 4px; }
.header p { margin: 2px 0; font-size: 13px; color: #555; }
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
.directions strong { display: block; margin-bottom: 4px; }
.footer { margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center; }
/* Pinned to the bottom of every printed page (not just after the
last question) — @page's bottom margin leaves room for it so it
never overlaps question text on the last page. */
@media print {
@page { margin: 20mm 20mm 28mm 20mm; }
/* @page's margin already insets content from the physical page
edge — body's own 40px padding (needed on-screen, for the
preview tab before printing) would double up with it here,
wasting real page height on every side and fitting noticeably
fewer questions per page than the paper actually has room for. */
body { -webkit-print-color-adjust: exact; padding: 0; max-width: none; }
.footer { position: fixed; bottom: 0; left: 0; right: 0; margin-top: 0; }
}
</style></head><body>
<div class="header">
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
<h1>${exam.title[locale] || exam.title.en}</h1>
<p>Date: ${exam.date} | Venue: ${exam.venue}</p>
<p>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"}</p>
<p>Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}</p>
</div>
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
${qHtml}
<div class="footer">
Generated by EMA — Ethiopian Maritime Authority
</div>
</body></html>
`);
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 (
<Stack gap="md" ref={printRef}>
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon
variant="subtle"
size="lg"
onClick={() => navigate("/exams")}
>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={2}>{exam.title[locale]}</Title>
</div>
</Group>
<Group gap="sm">
<Button
variant="light"
leftSection={<IconPrinter size={15} />}
onClick={handlePrint}
size="sm"
>
{t("exam.print")}
</Button>
<Button
leftSection={<IconPlus size={15} />}
onClick={openRecord}
size="sm"
>
{t("exam.recordResult")}
</Button>
</Group>
</Group>
{/* Status badge */}
<StatusBadge
tone={STATUS_TONE[exam.status]}
label={t(`exam.status.${exam.status}`)}
size="lg"
variant="light"
style={{ width: "fit-content" }}
/>
{/* Exam Info */}
<Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">
{t("exam.detail.title")}
</Title>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<InfoRow label={t("exam.detail.certification")} value={certName} />
<InfoRow
label={t("exam.detail.type")}
value={t(`exam.type.${exam.type}`)}
/>
<InfoRow
label={t("exam.detail.form")}
value={t(`exam.formType.${exam.form}`)}
/>
<InfoRow label={t("exam.detail.venue")} value={exam.venue} />
<InfoRow label={t("exam.detail.date")} value={exam.date} />
<InfoRow
label={t("exam.detail.administration")}
value={t(`exam.admin.${exam.administrationMethod}`)}
/>
<InfoRow
label={t("exam.detail.evaluation")}
value={t(`exam.eval.${exam.evaluationMethod}`)}
/>
<InfoRow
label={t("exam.detail.selection")}
value={t(`exam.selection.${exam.selectionMethod}`)}
/>
<InfoRow
label={t("exam.detail.timeAllowed")}
value={
exam.givenTime
? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m`
: "—"
}
/>
<InfoRow
label={t("exam.detail.passMark")}
value={String(exam.cuttingPoint)}
/>
<InfoRow
label={t("exam.detail.totalPoints")}
value={String(totalPoints)}
/>
<InfoRow
label={t("exam.detail.questions")}
value={String((exam.questions ?? []).length)}
/>
</SimpleGrid>
{(exam.direction?.en || exam.direction?.am) && (
<>
<Divider my="md" />
<InfoRow
label={t("exam.detail.directions")}
value={[exam.direction?.en, exam.direction?.am]
.filter(Boolean)
.join(" / ")}
/>
</>
)}
</Paper>
{/* Questions */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Title order={5}>
{t("exam.detail.questionsSection", { pts: totalPoints })}
</Title>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
>
{t("exam.manageQuestions")}
</Button>
</RequirePermission>
</Group>
{(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
{t("exam.noQuestionsAssigned")}
</Alert>
) : (
<Stack gap="md">
{(exam.questions ?? []).map((q, i) => (
<Paper key={q.id} withBorder p="md" radius="md">
<Group justify="space-between" mb="xs">
<Text fz="sm" fw={700}>
{t("exam.detail.questionLabel")} {i + 1}
</Text>
<Group gap={4}>
<Badge
size="xs"
variant="light"
color={q.form === "ESSAY" ? "blue" : "violet"}
>
{t(`exam.formType.${q.form}`)}
</Badge>
<Badge size="xs" variant="light" color="gray">
{q.points} pts
</Badge>
</Group>
</Group>
<Text fz="sm">{q.title[locale]}</Text>
</Paper>
))}
</Stack>
)}
</Paper>
{/* Exam-day operations: who sat the paper, and what went wrong */}
<ExamCandidatesPanel examId={exam.id} />
<ExamIncidentsPanel examId={exam.id} />
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
{/* Question assignment modal */}
<Modal
opened={assignOpened}
onClose={closeAssign}
title={`${t("exam.manageQuestions")}${exam.title[locale]}`}
size="xl"
radius="lg"
>
<Stack gap="md">
{exam.selectionMethod === "MANUAL" ? (
<>
<QuestionAssigner
available={eligibleQuestions}
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="manual"
/>
<ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</RequirePermission>
</ModalFooter>
</>
) : (
<>
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
<Group gap="sm">
<NumberInput
placeholder={t("exam.assigner.selectCount")}
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
size="xs"
style={{ width: 80 }}
/>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
{t('exam.randomSelect')}
</Button>
</RequirePermission>
</Group>
<QuestionAssigner
available={eligibleQuestions}
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="random"
/>
<ModalFooter>
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</RequirePermission>
</ModalFooter>
</>
)}
</Stack>
</Modal>
</Stack>
);
}