Files
emaui/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
Estifo77 163e02e7af test
2026-07-27 11:52:56 +03:00

549 lines
18 KiB
TypeScript

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<string, string> = {
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
};
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
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 {
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<actionTypes>();
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 (
<Center py="xl">
<Loader />
</Center>
);
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();
};
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<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" ? ["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; }
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
</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 style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
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={3}>{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 */}
<Badge
size="lg"
variant="light"
color={STATUS_COLOR[exam.status]}
style={{ width: "fit-content" }}
>
{t(`exam.status.${exam.status}`)}
</Badge>
{/* 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>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
>
{t("exam.manageQuestions")}
</Button>
</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>
<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"
actions={setWhatAction}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</Group>
</>
) : (
<>
<Text fz="sm" c="dimmed">
{t("exam.assigner.randomHint", {
total: eligibleQuestions.length,
pts: exam.cuttingPoint,
})}
</Text>
<Group gap="sm">
<NumberInput
placeholder={t("exam.assigner.selectCount")}
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
max={eligibleQuestions.length}
size="xs"
style={{ width: 80 }}
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
{t("exam.randomSelect")}
</Button>
</Group>
<QuestionAssigner
available={eligibleQuestions}
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="random"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">
{t("exam.cancel")}
</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
{t("exam.saveAssignments")}
</Button>
</Group>
</>
)}
</Stack>
</Modal>
</Stack>
);
}