mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 22:58:18 +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[];
|
available: QuestionBrief[];
|
||||||
assigned: QuestionBrief[];
|
assigned: QuestionBrief[];
|
||||||
onChange: (assigned: QuestionBrief[]) => void;
|
onChange: (assigned: QuestionBrief[]) => void;
|
||||||
|
mode?: 'manual' | 'random';
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuestionList({
|
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 [searchLeft, setSearchLeft] = useState('');
|
||||||
const [searchRight, setSearchRight] = useState('');
|
const [searchRight, setSearchRight] = useState('');
|
||||||
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
|
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
|
||||||
@@ -114,20 +115,23 @@ export function QuestionAssigner({ available, assigned, onChange }: QuestionAssi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<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">
|
<Group gap="sm" align="stretch" wrap="nowrap">
|
||||||
<QuestionList
|
{mode === 'manual' && (
|
||||||
items={filteredAvailable}
|
<QuestionList
|
||||||
selected={selectedLeft}
|
items={filteredAvailable}
|
||||||
onToggle={(id) => {
|
selected={selectedLeft}
|
||||||
const next = new Set(selectedLeft);
|
onToggle={(id) => {
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
const next = new Set(selectedLeft);
|
||||||
setSelectedLeft(next);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
}}
|
setSelectedLeft(next);
|
||||||
search={searchLeft}
|
}}
|
||||||
onSearchChange={setSearchLeft}
|
search={searchLeft}
|
||||||
label="Available Questions"
|
onSearchChange={setSearchLeft}
|
||||||
/>
|
label="Available Questions"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<QuestionList
|
<QuestionList
|
||||||
items={filteredAssigned}
|
items={filteredAssigned}
|
||||||
selected={selectedRight}
|
selected={selectedRight}
|
||||||
@@ -141,18 +145,27 @@ export function QuestionAssigner({ available, assigned, onChange }: QuestionAssi
|
|||||||
label="Assigned Questions"
|
label="Assigned Questions"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" justify="center">
|
{mode === 'manual' && (
|
||||||
{selectedLeft.size > 0 && (
|
<Group gap="sm" justify="center">
|
||||||
<Button size="xs" variant="light" onClick={assignSelected}>
|
{selectedLeft.size > 0 && (
|
||||||
Assign Selected ({selectedLeft.size})
|
<Button size="xs" variant="light" onClick={assignSelected}>
|
||||||
</Button>
|
Assign Selected ({selectedLeft.size})
|
||||||
)}
|
</Button>
|
||||||
{selectedRight.size > 0 && (
|
)}
|
||||||
|
{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}>
|
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
||||||
Remove Selected ({selectedRight.size})
|
Remove Selected ({selectedRight.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</Group>
|
||||||
</Group>
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
@@ -169,6 +169,13 @@ export function ExamPage() {
|
|||||||
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
|
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
|
||||||
const [randomCount, setRandomCount] = useState(5);
|
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 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 ?? '-';
|
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.en ?? '-';
|
||||||
|
|
||||||
@@ -220,20 +227,53 @@ export function ExamPage() {
|
|||||||
const handleRandomSelect = () => {
|
const handleRandomSelect = () => {
|
||||||
if (!assignTarget) return;
|
if (!assignTarget) return;
|
||||||
const assignedIds = new Set((assignTarget.questions ?? []).map((q) => q.id));
|
const assignedIds = new Set((assignTarget.questions ?? []).map((q) => q.id));
|
||||||
const unassigned = allQuestions
|
const currentTotal = (assignTarget.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
|
||||||
.filter((q) => !assignedIds.has(q.id))
|
const cuttingPoint = Number(assignTarget.cuttingPoint);
|
||||||
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
|
const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id));
|
||||||
const shuffled = [...unassigned].sort(() => Math.random() - 0.5);
|
|
||||||
const picked = shuffled.slice(0, Math.min(randomCount, shuffled.length));
|
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({
|
setAssignTarget({
|
||||||
...assignTarget,
|
...assignTarget,
|
||||||
questions: [...(assignTarget.questions ?? []), ...picked],
|
questions: [...(assignTarget.questions ?? []), ...picked],
|
||||||
});
|
});
|
||||||
notify.info(`Randomly selected ${picked.length} questions`);
|
notify.info(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssign = async () => {
|
const handleAssign = async () => {
|
||||||
if (!assignTarget) return;
|
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 {
|
try {
|
||||||
const questionIds = assignTarget.questions?.map((q) => q.id) ?? [];
|
const questionIds = assignTarget.questions?.map((q) => q.id) ?? [];
|
||||||
await assignQuestions({ examId: assignTarget.id, questionIds, remark: undefined }).unwrap();
|
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">
|
<Modal opened={assignOpened} onClose={closeAssign} title={`Assign Questions — ${assignTarget?.title?.en ?? ''}`} size="xl" radius="lg">
|
||||||
{assignTarget && (
|
{assignTarget && (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<QuestionAssigner
|
{assignTarget.selectionMethod === 'MANUAL' ? (
|
||||||
available={allQuestions.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }))}
|
<>
|
||||||
assigned={assignTarget.questions ?? []}
|
<QuestionAssigner
|
||||||
onChange={(updated) => {
|
available={eligibleQuestions}
|
||||||
setAssignTarget({ ...assignTarget, questions: updated });
|
assigned={assignTarget.questions ?? []}
|
||||||
}}
|
onChange={(updated) => {
|
||||||
/>
|
setAssignTarget({ ...assignTarget, questions: updated });
|
||||||
<Group justify="space-between">
|
}}
|
||||||
<Group gap="sm">
|
mode="manual"
|
||||||
<NumberInput
|
|
||||||
placeholder="Count"
|
|
||||||
value={randomCount}
|
|
||||||
onChange={(v) => setRandomCount(Number(v))}
|
|
||||||
min={1}
|
|
||||||
max={allQuestions.length}
|
|
||||||
size="xs"
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
/>
|
||||||
<Button size="xs" variant="light" onClick={handleRandomSelect}>
|
<Group justify="flex-end">
|
||||||
Randomly Select
|
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
|
||||||
</Button>
|
<Button onClick={handleAssign} size="sm">Save Assignments</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm">
|
</>
|
||||||
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
|
) : (
|
||||||
<Button onClick={handleAssign} size="sm">Save Assignments</Button>
|
<>
|
||||||
</Group>
|
<Text fz="sm" c="dimmed">
|
||||||
</Group>
|
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>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user