Files
starr-philproperties/src/modules/admin/pages/courses/CourseAssessment.jsx
T

734 lines
30 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}