mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
734 lines
30 KiB
React
734 lines
30 KiB
React
import { useEffect, useRef, useState } from "react";
|
||
import { useNavigate, useParams } from "react-router-dom";
|
||
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
|
||
|
||
import { toast } from "sonner";
|
||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||
import { useAuth } from "@/contexts/AuthContext";
|
||
import { PageMeta } from "@/contexts/MetadataContext";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Spinner } from "@/components/ui/spinner";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { cn } from "@/lib/utils";
|
||
import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor";
|
||
import api from "@/utils/api.util";
|
||
|
||
// ── Dirty-check snapshot ──────────────────────────────────────────────────────
|
||
|
||
function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) {
|
||
return JSON.stringify({
|
||
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions,
|
||
questions: questions.map((q) => ({
|
||
question_id: q.question_id ?? null,
|
||
question: q.question,
|
||
type: q.type,
|
||
points: q.points ?? 1,
|
||
options: (q.options ?? []).map((o) => ({
|
||
option_id: o.option_id ?? null,
|
||
text: o.text,
|
||
is_correct: o.is_correct,
|
||
})),
|
||
})),
|
||
});
|
||
}
|
||
|
||
// ── Validation ────────────────────────────────────────────────────────────────
|
||
|
||
function validate(questions) {
|
||
const errors = {};
|
||
questions.forEach((q, i) => {
|
||
const qErr = {};
|
||
if (!q.question?.trim()) qErr.question = "Question text is required.";
|
||
const correctCount = q.options.filter((o) => o.is_correct).length;
|
||
if (correctCount === 0) qErr.options = "At least one correct answer is required.";
|
||
if (q.options.some((o) => !o.text?.trim())) qErr.options = "All option texts are required.";
|
||
if (Object.keys(qErr).length) errors[i] = qErr;
|
||
});
|
||
return errors;
|
||
}
|
||
|
||
// ── Jump to input ─────────────────────────────────────────────────────────────
|
||
|
||
function JumpToInput({ max, onJump }) {
|
||
const [val, setVal] = useState("");
|
||
|
||
const handleJump = () => {
|
||
const n = parseInt(val, 10);
|
||
if (!isNaN(n) && n >= 1 && n <= max) {
|
||
onJump(n - 1);
|
||
setVal("");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex gap-1">
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={max}
|
||
value={val}
|
||
onChange={(e) => setVal(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && handleJump()}
|
||
placeholder={`1–${max}`}
|
||
className="h-7 text-xs"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 px-2 text-xs shrink-0"
|
||
onClick={handleJump}
|
||
>
|
||
Go
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Question Navigator ────────────────────────────────────────────────────────
|
||
|
||
const TYPE_LABEL = {
|
||
multiple_choice: "MC",
|
||
multi_select: "MS",
|
||
true_false: "TF",
|
||
};
|
||
|
||
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
|
||
{/* Header */}
|
||
<div className="px-3 py-3 border-b shrink-0">
|
||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||
Questions
|
||
</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
{questions.length} total
|
||
</p>
|
||
</div>
|
||
|
||
{/* Scrollable list */}
|
||
<div ref={navContainerRef} className="flex-1 overflow-y-auto py-2">
|
||
{questions.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground text-center py-8 px-3">
|
||
No questions yet.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-0.5 px-2">
|
||
{questions.map((q, i) => {
|
||
const isActive = i === activeIndex;
|
||
const hasError = !!errors?.[i];
|
||
const typeLabel = TYPE_LABEL[q.type] ?? "Q";
|
||
|
||
return (
|
||
<div
|
||
key={q._tempId ?? q.question_id ?? i}
|
||
ref={(el) => (navItemRefs.current[i] = el)}
|
||
onClick={() => onJump(i)}
|
||
className={cn(
|
||
"group flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
||
isActive
|
||
? "bg-primary/10 text-primary"
|
||
: hasError
|
||
? "bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||
: "hover:bg-muted text-foreground"
|
||
)}
|
||
>
|
||
{/* Number badge */}
|
||
<span className={cn(
|
||
"flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-semibold",
|
||
isActive
|
||
? "bg-primary text-primary-foreground"
|
||
: hasError
|
||
? "bg-destructive text-destructive-foreground"
|
||
: "bg-muted-foreground/20 text-muted-foreground"
|
||
)}>
|
||
{i + 1}
|
||
</span>
|
||
|
||
{/* Type */}
|
||
<span className="text-[10px] font-medium text-muted-foreground shrink-0 w-5">
|
||
{typeLabel}
|
||
</span>
|
||
|
||
{/* Question preview */}
|
||
<span className="flex-1 text-xs truncate min-w-0">
|
||
{q.question?.trim()
|
||
? q.question.trim()
|
||
: <span className="italic text-muted-foreground">Untitled</span>
|
||
}
|
||
</span>
|
||
|
||
{/* Move buttons — show on hover */}
|
||
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onMove(i, "up"); }}
|
||
disabled={i === 0}
|
||
className="h-4 w-4 flex items-center justify-center rounded hover:bg-muted-foreground/20 disabled:opacity-30 transition-colors"
|
||
>
|
||
<ChevronUp className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onMove(i, "down"); }}
|
||
disabled={i === questions.length - 1}
|
||
className="h-4 w-4 flex items-center justify-center rounded hover:bg-muted-foreground/20 disabled:opacity-30 transition-colors"
|
||
>
|
||
<ChevronDown className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Jump to — only when enough questions */}
|
||
{questions.length > 5 && (
|
||
<div className="px-3 py-3 border-t shrink-0 space-y-1.5">
|
||
<p className="text-xs text-muted-foreground">Jump to question</p>
|
||
<JumpToInput max={questions.length} onJump={onJump} />
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function CourseAssessment() {
|
||
const navigate = useNavigate();
|
||
const { courseId } = useParams();
|
||
const {
|
||
createAssessment, updateAssessment,
|
||
bulkSyncAssessmentQuestions,
|
||
course, loading,
|
||
} = useCourses();
|
||
|
||
const [localAssessment, setLocalAssessment] = useState(null);
|
||
const { user } = useAuth();
|
||
|
||
const [initializing, setInitializing] = useState(true);
|
||
const [questions, setQuestions] = useState([]);
|
||
const [errors, setErrors] = useState({});
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
|
||
const [title, setTitle] = useState("");
|
||
const [passingScore, setPassingScore] = useState(70);
|
||
const [timeLimit, setTimeLimit] = useState("");
|
||
const [isRequired, setIsRequired] = useState(false);
|
||
const [maxQuestions, setMaxQuestions] = useState("");
|
||
const [maxAttempts, setMaxAttempts] = useState(3);
|
||
const [cooldownHours, setCooldownHours] = useState(24);
|
||
const [shuffleQuestions, setShuffleQuestions] = useState(false);
|
||
|
||
// ── Update confirmation dialog ─────────────────────────────────────────────
|
||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||
const [inProgressCount, setInProgressCount] = useState(0);
|
||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||
const pendingSaveRef = useRef(null); // stores the meta+questions payload until confirmed
|
||
const initialSnapshot = useRef(null);
|
||
|
||
const questionRefs = useRef([]);
|
||
const navItemRefs = useRef([]);
|
||
const navContainerRef = useRef(null);
|
||
const headerRef = useRef(null);
|
||
|
||
// ── Fetch — silently treat 404 as "no assessment yet" (create mode) ─────────
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
|
||
const result = data?.data?.data ?? null;
|
||
setLocalAssessment(result);
|
||
} catch (err) {
|
||
if (err?.response?.status !== 404) {
|
||
toast(err?.response?.data?.message ?? "Could not load assessment.");
|
||
}
|
||
} finally {
|
||
setInitializing(false);
|
||
}
|
||
})();
|
||
}, [courseId]);
|
||
|
||
// ── Seed ──────────────────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
if (!localAssessment) return;
|
||
const t = localAssessment.title ?? "";
|
||
const ps = localAssessment.passing_score ?? 70;
|
||
const tl = localAssessment.time_limit_minutes ?? "";
|
||
const ir = localAssessment.is_required === true || localAssessment.is_required === 1;
|
||
const mq = localAssessment.max_questions ?? "";
|
||
const ma = localAssessment.max_attempts ?? 3;
|
||
const ch = localAssessment.cooldown_hours ?? 24;
|
||
const sq = localAssessment.shuffle_questions === true || localAssessment.shuffle_questions === 1;
|
||
const qs = (localAssessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
||
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
|
||
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
|
||
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs });
|
||
}, [localAssessment]);
|
||
|
||
// ── Measure sticky header → --assessment-h ────────────────────────────────
|
||
useEffect(() => {
|
||
if (!headerRef.current) return;
|
||
const update = () => {
|
||
document.documentElement.style.setProperty(
|
||
"--assessment-h",
|
||
`${headerRef.current.offsetHeight}px`
|
||
);
|
||
};
|
||
update();
|
||
window.addEventListener("resize", update);
|
||
return () => window.removeEventListener("resize", update);
|
||
}, []);
|
||
|
||
// ── Scroll to keep active nav item visible ─────────────────────────────────
|
||
useEffect(() => {
|
||
const item = navItemRefs.current[activeIndex];
|
||
const container = navContainerRef.current;
|
||
if (!item || !container) return;
|
||
|
||
const itemTop = item.offsetTop;
|
||
const itemBottom = itemTop + item.offsetHeight;
|
||
const viewTop = container.scrollTop;
|
||
const viewBottom = viewTop + container.clientHeight;
|
||
|
||
if (itemTop < viewTop) {
|
||
container.scrollTop = itemTop;
|
||
} else if (itemBottom > viewBottom) {
|
||
container.scrollTop = itemBottom - container.clientHeight;
|
||
}
|
||
}, [activeIndex]);
|
||
|
||
// ── IntersectionObserver — highlight nav as user scrolls ──────────────────
|
||
useEffect(() => {
|
||
if (!questions.length) return;
|
||
const observers = [];
|
||
|
||
questionRefs.current.forEach((el, i) => {
|
||
if (!el) return;
|
||
const observer = new IntersectionObserver(
|
||
([entry]) => { if (entry.isIntersecting) setActiveIndex(i); },
|
||
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
||
);
|
||
observer.observe(el);
|
||
observers.push(observer);
|
||
});
|
||
|
||
return () => observers.forEach((o) => o.disconnect());
|
||
}, [questions.length]);
|
||
|
||
// ── Scroll helper with sticky offset ──────────────────────────────────────
|
||
const scrollToQuestion = (index) => {
|
||
const el = questionRefs.current[index];
|
||
if (!el) return;
|
||
const navbarH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--navbar-h") || "0", 10);
|
||
const assessmentH = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--assessment-h") || "0", 10);
|
||
const offset = navbarH + assessmentH + 16;
|
||
const top = el.getBoundingClientRect().top + window.scrollY - offset;
|
||
window.scrollTo({ top, behavior: "smooth" });
|
||
};
|
||
|
||
// ── Question actions ───────────────────────────────────────────────────────
|
||
const addQuestion = (type = "multiple_choice") => {
|
||
setQuestions((prev) => {
|
||
const next = [...prev, { ...makeQuestion(type), order_index: prev.length }];
|
||
setTimeout(() => {
|
||
const idx = next.length - 1;
|
||
setActiveIndex(idx);
|
||
scrollToQuestion(idx);
|
||
}, 50);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const updateQuestion = (index, updated) => {
|
||
setQuestions((prev) => prev.map((q, i) => i === index ? updated : q));
|
||
setErrors((prev) => { const e = { ...prev }; delete e[index]; return e; });
|
||
};
|
||
|
||
const removeQuestion = (index) => {
|
||
setQuestions((prev) => prev.filter((_, i) => i !== index));
|
||
setActiveIndex((prev) => Math.max(0, prev >= index ? prev - 1 : prev));
|
||
};
|
||
|
||
const moveQuestion = (index, direction) => {
|
||
setQuestions((prev) => {
|
||
const next = [...prev];
|
||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||
if (swapIndex < 0 || swapIndex >= next.length) return prev;
|
||
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
|
||
return next;
|
||
});
|
||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||
setActiveIndex(newIndex);
|
||
setTimeout(() => scrollToQuestion(newIndex), 50);
|
||
};
|
||
|
||
const jumpTo = (index) => {
|
||
setActiveIndex(index);
|
||
scrollToQuestion(index);
|
||
};
|
||
|
||
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||
|
||
// ── Dirty tracking ─────────────────────────────────────────────────────────
|
||
const isDirty = initialSnapshot.current === null
|
||
? questions.length > 0
|
||
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) !== initialSnapshot.current;
|
||
|
||
// ── Save ───────────────────────────────────────────────────────────────────
|
||
const handleSave = async () => {
|
||
const errs = validate(questions);
|
||
if (Object.keys(errs).length) {
|
||
setErrors(errs);
|
||
jumpTo(parseInt(Object.keys(errs)[0], 10));
|
||
return;
|
||
}
|
||
|
||
const assessmentId = localAssessment?.assessment_id;
|
||
const meta = {
|
||
title: title || "Course Assessment",
|
||
passing_score: passingScore,
|
||
time_limit_minutes: timeLimit ? parseInt(timeLimit) : null,
|
||
is_required: isRequired,
|
||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||
max_attempts: parseInt(maxAttempts) || 3,
|
||
cooldown_hours: parseInt(cooldownHours) || 24,
|
||
shuffle_questions: shuffleQuestions,
|
||
updatedBy: user?.user_id,
|
||
createdBy: user?.user_id,
|
||
};
|
||
|
||
// New assessment — no students can be in progress yet, save directly.
|
||
if (!assessmentId) {
|
||
await executeSave(null, meta);
|
||
return;
|
||
}
|
||
|
||
// Existing assessment — fetch in_progress count then show mandatory confirmation.
|
||
setConfirmLoading(true);
|
||
try {
|
||
const { data } = await api.get(`/admin/courses/${courseId}/assessment/${assessmentId}/sessions`);
|
||
const count = (data?.data?.sessions ?? []).filter((s) => s.status === 'in_progress').length;
|
||
setInProgressCount(count);
|
||
} catch {
|
||
setInProgressCount(0);
|
||
} finally {
|
||
setConfirmLoading(false);
|
||
}
|
||
pendingSaveRef.current = { assessmentId, meta };
|
||
setConfirmOpen(true);
|
||
};
|
||
|
||
const executeSave = async (assessmentId, meta) => {
|
||
let id = assessmentId;
|
||
if (!id) {
|
||
const res = await createAssessment(courseId, meta);
|
||
id = res?.data?.data?.data?.assessment_id;
|
||
if (!id) return;
|
||
setLocalAssessment((prev) => ({ ...prev, assessment_id: id }));
|
||
} else {
|
||
await updateAssessment(courseId, id, meta);
|
||
}
|
||
|
||
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id);
|
||
|
||
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
|
||
};
|
||
|
||
const handleConfirmSave = async () => {
|
||
const { assessmentId, meta } = pendingSaveRef.current ?? {};
|
||
if (!assessmentId) return;
|
||
await executeSave(assessmentId, meta);
|
||
pendingSaveRef.current = null;
|
||
setConfirmOpen(false);
|
||
};
|
||
|
||
// ── Render ─────────────────────────────────────────────────────────────────
|
||
return (
|
||
<div className="flex flex-col min-h-screen bg-muted/60">
|
||
<PageMeta title={course ? `${course.title} – Assessment - STARR` : undefined} />
|
||
|
||
{/* ── Sticky header ── */}
|
||
<div
|
||
ref={headerRef}
|
||
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||
style={{ top: "var(--navbar-h)" }}
|
||
>
|
||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||
<div className="flex items-center gap-3 py-3">
|
||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Button>
|
||
<div className="flex-1 min-w-0">
|
||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||
Course Assessment
|
||
</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||
</p>
|
||
</div>
|
||
<Button onClick={handleSave} disabled={loading || confirmLoading || !isDirty}>
|
||
{(loading || confirmLoading) ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
|
||
Save Assessment
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Split layout ── */}
|
||
<div className="flex flex-1 lg:container lg:mx-auto lg:px-6 px-0 w-full items-start">
|
||
|
||
{/* LEFT — Navigator (desktop only) */}
|
||
<div
|
||
className="hidden lg:flex flex-col w-60 shrink-0 border-r bg-background"
|
||
style={{
|
||
position: "sticky",
|
||
top: `calc(var(--navbar-h) + var(--assessment-h, 0px))`,
|
||
height: `calc(100vh - var(--navbar-h) - var(--assessment-h, 0px))`,
|
||
}}
|
||
>
|
||
<QuestionNavigator
|
||
questions={questions}
|
||
activeIndex={activeIndex}
|
||
onJump={jumpTo}
|
||
onMove={moveQuestion}
|
||
navItemRefs={navItemRefs}
|
||
navContainerRef={navContainerRef}
|
||
errors={errors}
|
||
/>
|
||
</div>
|
||
|
||
{/* RIGHT — Main content */}
|
||
<div className="flex-1 min-w-0 px-4 lg:px-8 py-6">
|
||
{initializing ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<Spinner className="h-6 w-6" />
|
||
</div>
|
||
) : (
|
||
<div className="max-w-5xl space-y-6 pb-16">
|
||
|
||
{/* ── Settings ── */}
|
||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||
<p className="text-sm font-medium">Settings</p>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label>Title</Label>
|
||
<Input
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="Course Assessment"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<Label>Passing Score (%)</Label>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
max={100}
|
||
value={passingScore}
|
||
onChange={(e) => setPassingScore(parseInt(e.target.value) || 0)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>
|
||
Time Limit{" "}
|
||
<span className="text-muted-foreground font-normal text-xs">(mins, blank = none)</span>
|
||
</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
value={timeLimit}
|
||
onChange={(e) => setTimeLimit(e.target.value)}
|
||
placeholder="No limit"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>
|
||
Max Questions{" "}
|
||
<span className="text-muted-foreground font-normal text-xs">(blank = show all)</span>
|
||
</Label>
|
||
<Input
|
||
type="number" min={1} max={questions.length || undefined}
|
||
value={maxQuestions}
|
||
onChange={(e) => setMaxQuestions(e.target.value)}
|
||
placeholder={`All (${questions.length})`}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>
|
||
Max Failed Attempts{" "}
|
||
<span className="text-muted-foreground font-normal text-xs">before cooldown</span>
|
||
</Label>
|
||
<Input
|
||
type="number" min={1}
|
||
value={maxAttempts}
|
||
onChange={(e) => setMaxAttempts(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>
|
||
Cooldown{" "}
|
||
<span className="text-muted-foreground font-normal text-xs">(hours after max fails)</span>
|
||
</Label>
|
||
<Input
|
||
type="number" min={1}
|
||
value={cooldownHours}
|
||
onChange={(e) => setCooldownHours(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-3">
|
||
<Checkbox id="assessment_required" checked={isRequired === true} disabled />
|
||
<Label htmlFor="assessment_required" className="text-muted-foreground">
|
||
Required to complete course
|
||
</Label>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground pl-7">
|
||
Derived from this course's Completion Requirements — add or remove a "Pass the Quiz"
|
||
requirement on the Requirements step of the course editor to change this.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Checkbox
|
||
id="assessment_shuffle"
|
||
checked={shuffleQuestions === true}
|
||
onCheckedChange={(val) => setShuffleQuestions(val)}
|
||
/>
|
||
<Label htmlFor="assessment_shuffle" className="cursor-pointer">
|
||
Shuffle question order for each attempt
|
||
</Label>
|
||
</div>
|
||
</div>
|
||
|
||
{maxQuestions && parseInt(maxQuestions) < questions.length && (
|
||
<p className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
|
||
Takers will see <strong>{maxQuestions}</strong> randomly selected questions
|
||
out of <strong>{questions.length}</strong> in the pool.
|
||
</p>
|
||
)}
|
||
|
||
{/* ── Questions ── */}
|
||
<div className="space-y-4">
|
||
{questions.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
||
<ClipboardList className="h-8 w-8 text-muted-foreground/40" />
|
||
<p className="text-sm text-muted-foreground">
|
||
No questions yet. Add one below.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
questions.map((q, i) => (
|
||
<div
|
||
key={q._tempId ?? q.question_id ?? i}
|
||
ref={(el) => (questionRefs.current[i] = el)}
|
||
onClick={() => setActiveIndex(i)}
|
||
>
|
||
<QuestionCard
|
||
question={q}
|
||
index={i}
|
||
onChange={(updated) => updateQuestion(i, updated)}
|
||
onRemove={() => removeQuestion(i)}
|
||
error={errors[i]}
|
||
/>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Add question ── */}
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-3">
|
||
Add Question
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{[
|
||
{ type: "multiple_choice", label: "Multiple Choice" },
|
||
{ type: "multi_select", label: "Multi Select" },
|
||
{ type: "true_false", label: "True / False" },
|
||
].map(({ type, label }) => (
|
||
<Button
|
||
key={type}
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => addQuestion(type)}
|
||
>
|
||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||
{label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Update confirmation dialog ── */}
|
||
{confirmOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||
<div className="bg-background rounded-xl border shadow-xl w-full max-w-md p-6 space-y-5">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
|
||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-base font-semibold">Save Assessment Changes?</h2>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
Changes will take effect immediately for all students.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{inProgressCount > 0 && (
|
||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 flex items-center gap-2">
|
||
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||
<p className="text-sm text-amber-700 dark:text-amber-400 font-medium">
|
||
{inProgressCount} student{inProgressCount !== 1 ? "s are" : " is"} currently taking this assessment.
|
||
Saving now will affect their ongoing session.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<ul className="text-sm text-muted-foreground space-y-1 pl-1">
|
||
<li>• New passing score applies to future submissions only.</li>
|
||
<li>• Changing the time limit does not affect already-started sessions.</li>
|
||
<li>• Adding or removing questions affects any student not yet on that question.</li>
|
||
</ul>
|
||
|
||
<div className="flex gap-3 justify-end pt-1">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => { setConfirmOpen(false); pendingSaveRef.current = null; }}
|
||
disabled={loading}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={handleConfirmSave}
|
||
disabled={loading}
|
||
>
|
||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : null}
|
||
Save Anyway
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} |