feat(exam): implement dynamic question assignment modes and validation logic commit

This commit is contained in:
mengstabketemaw
2026-06-27 11:02:29 +03:00
parent b78b43a5c1
commit 0b9322f016
2 changed files with 130 additions and 56 deletions

View File

@@ -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<Set<string>>(new Set());
@@ -114,20 +115,23 @@ export function QuestionAssigner({ available, assigned, onChange }: QuestionAssi
return (
<Stack gap="sm">
<Text fz="sm" fw={500}>Assign Questions to Exam</Text>
{mode === 'manual' && <Text fz="sm" fw={500}>Assign Questions to Exam</Text>}
{mode === 'random' && <Text fz="sm" fw={500}>Assigned Questions</Text>}
<Group gap="sm" align="stretch" wrap="nowrap">
<QuestionList
items={filteredAvailable}
selected={selectedLeft}
onToggle={(id) => {
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' && (
<QuestionList
items={filteredAvailable}
selected={selectedLeft}
onToggle={(id) => {
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"
/>
)}
<QuestionList
items={filteredAssigned}
selected={selectedRight}
@@ -141,18 +145,27 @@ export function QuestionAssigner({ available, assigned, onChange }: QuestionAssi
label="Assigned Questions"
/>
</Group>
<Group gap="sm" justify="center">
{selectedLeft.size > 0 && (
<Button size="xs" variant="light" onClick={assignSelected}>
Assign Selected ({selectedLeft.size})
</Button>
)}
{selectedRight.size > 0 && (
{mode === 'manual' && (
<Group gap="sm" justify="center">
{selectedLeft.size > 0 && (
<Button size="xs" variant="light" onClick={assignSelected}>
Assign Selected ({selectedLeft.size})
</Button>
)}
{selectedRight.size > 0 && (
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
Remove Selected ({selectedRight.size})
</Button>
)}
</Group>
)}
{mode === 'random' && selectedRight.size > 0 && (
<Group gap="sm" justify="center">
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
Remove Selected ({selectedRight.size})
</Button>
)}
</Group>
</Group>
)}
</Stack>
);
}

View File

@@ -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() {
<Modal opened={assignOpened} onClose={closeAssign} title={`Assign Questions — ${assignTarget?.title?.en ?? ''}`} size="xl" radius="lg">
{assignTarget && (
<Stack gap="md">
<QuestionAssigner
available={allQuestions.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }))}
assigned={assignTarget.questions ?? []}
onChange={(updated) => {
setAssignTarget({ ...assignTarget, questions: updated });
}}
/>
<Group justify="space-between">
<Group gap="sm">
<NumberInput
placeholder="Count"
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
max={allQuestions.length}
size="xs"
style={{ width: 80 }}
{assignTarget.selectionMethod === 'MANUAL' ? (
<>
<QuestionAssigner
available={eligibleQuestions}
assigned={assignTarget.questions ?? []}
onChange={(updated) => {
setAssignTarget({ ...assignTarget, questions: updated });
}}
mode="manual"
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
Randomly Select
</Button>
</Group>
<Group gap="sm">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm">Save Assignments</Button>
</Group>
</Group>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm">Save Assignments</Button>
</Group>
</>
) : (
<>
<Text fz="sm" c="dimmed">
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).
</Text>
<Group gap="sm">
<NumberInput
placeholder="Count"
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
max={eligibleQuestions.length}
size="xs"
style={{ width: 80 }}
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
Randomly Select
</Button>
</Group>
<QuestionAssigner
available={eligibleQuestions}
assigned={assignTarget.questions ?? []}
onChange={(updated) => {
setAssignTarget({ ...assignTarget, questions: updated });
}}
mode="random"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm">Save Assignments</Button>
</Group>
</>
)}
</Stack>
)}
</Modal>