mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 23:35:43 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -38,19 +38,22 @@ import {
|
||||
IconUser,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDetails
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler, ModalFooter } from "@ema-platform/ui";
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
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";
|
||||
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 type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
@@ -100,31 +103,27 @@ export function ExamDetailPage() {
|
||||
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 [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 ?? [];
|
||||
const [whatAction, setWhatAction] = useState<actionTypes>();
|
||||
|
||||
// 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.certificationId === exam.certificationId &&
|
||||
q.form === exam.form &&
|
||||
q.status === 'APPROVED',
|
||||
)
|
||||
.map((q) => ({
|
||||
id: q.id,
|
||||
title: q.title,
|
||||
form: q.form,
|
||||
points: q.points,
|
||||
}));
|
||||
.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]);
|
||||
|
||||
@@ -158,75 +157,40 @@ export function ExamDetailPage() {
|
||||
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) {
|
||||
/**
|
||||
* 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(
|
||||
`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`,
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key,
|
||||
);
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
};
|
||||
const handlePrint = async () => {
|
||||
@@ -470,11 +434,11 @@ export function ExamDetailPage() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<RecordResultModal
|
||||
exam={exam}
|
||||
opened={recordOpened}
|
||||
onClose={closeRecord}
|
||||
/>
|
||||
{/* 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
|
||||
@@ -505,24 +469,18 @@ export function ExamDetailPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.assigner.randomHint", {
|
||||
total: eligibleQuestions.length,
|
||||
pts: exam.cuttingPoint,
|
||||
})}
|
||||
</Text>
|
||||
<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}
|
||||
max={eligibleQuestions.length}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Button size="xs" variant="light" onClick={handleRandomSelect}>
|
||||
{t("exam.randomSelect")}
|
||||
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
|
||||
{t('exam.randomSelect')}
|
||||
</Button>
|
||||
</Group>
|
||||
<QuestionAssigner
|
||||
|
||||
Reference in New Issue
Block a user