mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 18:48:12 +00:00
The detail page fired assign/draw requests blind and surfaced the raw backend key to the user — a locked paper showed up as the literal string "paper_locked_after_registration" in a toast. Manage Questions is now disabled once any candidate has registered, with a tooltip and badge explaining why; both the manual assign and the random draw live behind that modal, so one guard covers the same ground the backend's assertPaperEditable does. The empty-paper alert now explains the deadlock instead of telling the user to click a button that will fail, and both error handlers translate paper_locked_after_registration in case someone registers while the modal is open. Registrations come from the query ExamCandidatesPanel already runs on this page, so RTK Query serves it from cache rather than issuing a second request.
614 lines
22 KiB
TypeScript
614 lines
22 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,
|
|
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<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 });
|
|
// 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 <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 === '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<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>
|
|
<Group gap="xs">
|
|
{paperLocked && (
|
|
<Badge size="sm" variant="light" color="gray">
|
|
{t("exam.paperLocked")}
|
|
</Badge>
|
|
)}
|
|
<Tooltip
|
|
label={t("exam.paperLockedHint", {
|
|
count: registrations?.length ?? 0,
|
|
})}
|
|
disabled={!paperLocked}
|
|
multiline
|
|
w={280}
|
|
>
|
|
{/* Wrapped: a disabled Mantine Button fires no pointer events,
|
|
so the tooltip needs an enabled element to hang off. */}
|
|
<Box>
|
|
<Button
|
|
variant="light"
|
|
size="xs"
|
|
leftSection={<IconPlus size={14} />}
|
|
onClick={openAssignModal}
|
|
disabled={paperLocked}
|
|
>
|
|
{t("exam.manageQuestions")}
|
|
</Button>
|
|
</Box>
|
|
</Tooltip>
|
|
</Group>
|
|
</RequirePermission>
|
|
</Group>
|
|
{(exam.questions ?? []).length === 0 ? (
|
|
<Alert
|
|
color={paperLocked ? "red" : "gray"}
|
|
icon={<IconInfoCircle size={16} />}
|
|
>
|
|
{paperLocked
|
|
? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
|
|
: 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>
|
|
);
|
|
}
|