Adding more functionalities for all the license types

This commit is contained in:
Mulu Mehari
2026-08-07 11:50:07 +03:00
parent 35fb817b4b
commit 7c968c7093
66 changed files with 6468 additions and 3518 deletions

View File

@@ -40,11 +40,19 @@ import {
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation } from '../api/exam-api';
import { extractErrorMessage } from '@ema-platform/api';
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 type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
@@ -78,6 +86,7 @@ export function ExamDetailPage() {
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();
@@ -85,10 +94,17 @@ export function ExamDetailPage() {
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)
.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]);
@@ -109,43 +125,25 @@ 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;
/**
* 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,
);
}
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 () => {
@@ -154,8 +152,13 @@ export function ExamDetailPage() {
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
notify.success('Questions assigned');
closeAssign();
} catch {
notify.error('Failed to assign questions');
} catch (error) {
const key = extractErrorMessage(error, 'Failed to assign questions');
notify.error(
key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable')
: key,
);
}
};
@@ -311,6 +314,10 @@ export function ExamDetailPage() {
)}
</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 */}
@@ -331,20 +338,17 @@ 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}>
<Button size="xs" variant="light" loading={isDrawing} onClick={handleRandomSelect}>
{t('exam.randomSelect')}
</Button>
</Group>