diff --git a/apps/backoffice/src/app/features/exam/components/QuestionAssigner.tsx b/apps/backoffice/src/app/features/exam/components/QuestionAssigner.tsx index d5cb1d625..608184138 100644 --- a/apps/backoffice/src/app/features/exam/components/QuestionAssigner.tsx +++ b/apps/backoffice/src/app/features/exam/components/QuestionAssigner.tsx @@ -18,6 +18,7 @@ interface QuestionAssignerProps { available: QuestionBrief[]; assigned: QuestionBrief[]; onChange: (assigned: QuestionBrief[]) => void; + mode?: 'manual' | 'random'; } function QuestionList({ @@ -86,7 +87,7 @@ function QuestionList({ ); } -export function QuestionAssigner({ available, assigned, onChange }: QuestionAssignerProps) { +export function QuestionAssigner({ available, assigned, onChange, mode = 'manual' }: QuestionAssignerProps) { const [searchLeft, setSearchLeft] = useState(''); const [searchRight, setSearchRight] = useState(''); const [selectedLeft, setSelectedLeft] = useState>(new Set()); @@ -114,20 +115,23 @@ export function QuestionAssigner({ available, assigned, onChange }: QuestionAssi return ( - Assign Questions to Exam + {mode === 'manual' && Assign Questions to Exam} + {mode === 'random' && Assigned Questions} - { - const next = new Set(selectedLeft); - if (next.has(id)) next.delete(id); else next.add(id); - setSelectedLeft(next); - }} - search={searchLeft} - onSearchChange={setSearchLeft} - label="Available Questions" - /> + {mode === 'manual' && ( + { + const next = new Set(selectedLeft); + if (next.has(id)) next.delete(id); else next.add(id); + setSelectedLeft(next); + }} + search={searchLeft} + onSearchChange={setSearchLeft} + label="Available Questions" + /> + )} - - {selectedLeft.size > 0 && ( - - )} - {selectedRight.size > 0 && ( + {mode === 'manual' && ( + + {selectedLeft.size > 0 && ( + + )} + {selectedRight.size > 0 && ( + + )} + + )} + {mode === 'random' && selectedRight.size > 0 && ( + - )} - + + )} ); } diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx index 5ea72d484..3ba75bc30 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Stack, @@ -169,6 +169,13 @@ export function ExamPage() { const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false); const [randomCount, setRandomCount] = useState(5); + const eligibleQuestions = useMemo(() => { + if (!assignTarget) return []; + return allQuestions + .filter((q) => q.certificationId === assignTarget.certificationId && q.form === assignTarget.form) + .map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points })); + }, [allQuestions, assignTarget?.certificationId, assignTarget?.form]); + const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name.en })); const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.en ?? '-'; @@ -220,20 +227,53 @@ export function ExamPage() { const handleRandomSelect = () => { if (!assignTarget) return; const assignedIds = new Set((assignTarget.questions ?? []).map((q) => q.id)); - const unassigned = allQuestions - .filter((q) => !assignedIds.has(q.id)) - .map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points })); - const shuffled = [...unassigned].sort(() => Math.random() - 0.5); - const picked = shuffled.slice(0, Math.min(randomCount, shuffled.length)); + const currentTotal = (assignTarget.questions ?? []).reduce((s, q) => s + Number(q.points), 0); + const cuttingPoint = Number(assignTarget.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`; + setAssignTarget({ ...assignTarget, questions: [...(assignTarget.questions ?? []), ...picked], }); - notify.info(`Randomly selected ${picked.length} questions`); + notify.info(msg); }; const handleAssign = async () => { if (!assignTarget) return; + const totalPoints = (assignTarget.questions ?? []).reduce((s, q) => s + Number(q.points), 0); + if (totalPoints < assignTarget.cuttingPoint) { + notify.error(`Total question points (${totalPoints}) is less than the passing mark (${assignTarget.cuttingPoint}). Add more questions or adjust the cutting point.`); + return; + } try { const questionIds = assignTarget.questions?.map((q) => q.id) ?? []; await assignQuestions({ examId: assignTarget.id, questionIds, remark: undefined }).unwrap(); @@ -344,33 +384,54 @@ export function ExamPage() { {assignTarget && ( - ({ id: q.id, title: q.title, form: q.form, points: q.points }))} - assigned={assignTarget.questions ?? []} - onChange={(updated) => { - setAssignTarget({ ...assignTarget, questions: updated }); - }} - /> - - - setRandomCount(Number(v))} - min={1} - max={allQuestions.length} - size="xs" - style={{ width: 80 }} + {assignTarget.selectionMethod === 'MANUAL' ? ( + <> + { + setAssignTarget({ ...assignTarget, questions: updated }); + }} + mode="manual" /> - - - - - - - + + + + + + ) : ( + <> + + Randomly select questions from the pool of {eligibleQuestions.length} eligible questions. The selection will automatically ensure total points meet the passing mark ({assignTarget.cuttingPoint} pts). + + + setRandomCount(Number(v))} + min={1} + max={eligibleQuestions.length} + size="xs" + style={{ width: 80 }} + /> + + + { + setAssignTarget({ ...assignTarget, questions: updated }); + }} + mode="random" + /> + + + + + + )} )}