mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
549 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|