mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(exam): implement dynamic question assignment modes and validation logic commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user