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 (
setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleJump()}
placeholder={`1–${max}`}
className="h-7 text-xs"
/>
Go
);
}
// ── Question Navigator ────────────────────────────────────────────────────────
const TYPE_LABEL = {
multiple_choice: "MC",
multi_select: "MS",
true_false: "TF",
};
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
return (
{/* Header */}
Questions
{questions.length} total
{/* Scrollable list */}
{questions.length === 0 ? (
No questions yet.
) : (
{questions.map((q, i) => {
const isActive = i === activeIndex;
const hasError = !!errors?.[i];
const typeLabel = TYPE_LABEL[q.type] ?? "Q";
return (
(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 */}
{i + 1}
{/* Type */}
{typeLabel}
{/* Question preview */}
{q.question?.trim()
? q.question.trim()
: Untitled
}
{/* Move buttons — show on hover */}
{ 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"
>
{ 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"
>
);
})}
)}
{/* Jump to — only when enough questions */}
{questions.length > 5 && (
)}
);
}
// ── 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 (
{/* ── Sticky header ── */}
navigate("/admin/courses")}>
Course Assessment
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
{(loading || confirmLoading) ? : }
Save Assessment
{/* ── Split layout ── */}
{/* LEFT — Navigator (desktop only) */}
{/* RIGHT — Main content */}
{initializing ? (
) : (
{/* ── Settings ── */}
Settings
Title
setTitle(e.target.value)}
placeholder="Course Assessment"
/>
Required to complete course
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.
setShuffleQuestions(val)}
/>
Shuffle question order for each attempt
{maxQuestions && parseInt(maxQuestions) < questions.length && (
Takers will see {maxQuestions} randomly selected questions
out of {questions.length} in the pool.
)}
{/* ── Questions ── */}
{questions.length === 0 ? (
No questions yet. Add one below.
) : (
questions.map((q, i) => (
(questionRefs.current[i] = el)}
onClick={() => setActiveIndex(i)}
>
updateQuestion(i, updated)}
onRemove={() => removeQuestion(i)}
error={errors[i]}
/>
))
)}
{/* ── Add question ── */}
Add Question
{[
{ type: "multiple_choice", label: "Multiple Choice" },
{ type: "multi_select", label: "Multi Select" },
{ type: "true_false", label: "True / False" },
].map(({ type, label }) => (
addQuestion(type)}
>
{label}
))}
)}
{/* ── Update confirmation dialog ── */}
{confirmOpen && (
Save Assessment Changes?
Changes will take effect immediately for all students.
{inProgressCount > 0 && (
{inProgressCount} student{inProgressCount !== 1 ? "s are" : " is"} currently taking this assessment.
Saving now will affect their ongoing session.
)}
• New passing score applies to future submissions only.
• Changing the time limit does not affect already-started sessions.
• Adding or removing questions affects any student not yet on that question.
{ setConfirmOpen(false); pendingSaveRef.current = null; }}
disabled={loading}
>
Cancel
{loading ? : null}
Save Anyway
)}
);
}