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