change things

This commit is contained in:
rgrgogu
2026-08-03 13:31:51 +08:00
parent 6be0c29850
commit 8dfa54a731
10 changed files with 420 additions and 385 deletions
@@ -0,0 +1,804 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
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 "./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,
})),
})),
});
}
// ── Server row → form state ──────────────────────────────────────────────────
function toFormState(a) {
return {
title: a?.title ?? "",
passingScore: a?.passing_score ?? 70,
timeLimit: a?.time_limit_minutes ?? "",
isRequired: a?.is_required === true || a?.is_required === 1,
maxQuestions: a?.max_questions ?? "",
maxAttempts: a?.max_attempts ?? 3,
cooldownHours: a?.cooldown_hours ?? 24,
shuffleQuestions: a?.shuffle_questions === true || a?.shuffle_questions === 1,
questions: (a?.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] })),
};
}
// ── 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 rounded-lg border bg-card overflow-hidden">
{/* 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="max-h-96 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 ──────────────────────────────────────────────────────────────────────
export default function AssessmentEditor({ courseId, onSaved, onCancel }) {
const {
createAssessment, updateAssessment,
bulkSyncAssessmentQuestions,
loading,
} = useCourses();
const [localAssessment, setLocalAssessment] = useState(null);
const { user } = useAuth();
const DRAFT_KEY = `draftAssessment_${courseId}`;
const [draftInfo, setDraftInfo] = useState(null); // { savedAt } — non-null while an unsaved local draft exists
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 originalQuestionIdsRef = useRef([]); // ids loaded from the server — used to detect a full-pool wipe on save
const questionRefs = useRef([]);
const navItemRefs = useRef([]);
const navContainerRef = 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 s = toFormState(localAssessment);
setTitle(s.title); setPassingScore(s.passingScore); setTimeLimit(s.timeLimit); setIsRequired(s.isRequired);
setMaxQuestions(s.maxQuestions); setMaxAttempts(s.maxAttempts); setCooldownHours(s.cooldownHours);
setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
initialSnapshot.current = snapAssessment(s);
originalQuestionIdsRef.current = s.questions.map((q) => q.question_id).filter(Boolean);
}, [localAssessment]);
// ── Restore an unsaved local draft (survives a reload before Save Assessment) ─
useEffect(() => {
if (initializing) return;
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (!draft?.questions) return;
setTitle(draft.title ?? "");
setPassingScore(draft.passingScore ?? 70);
setTimeLimit(draft.timeLimit ?? "");
setIsRequired(draft.isRequired ?? false);
setMaxQuestions(draft.maxQuestions ?? "");
setMaxAttempts(draft.maxAttempts ?? 3);
setCooldownHours(draft.cooldownHours ?? 24);
setShuffleQuestions(draft.shuffleQuestions ?? false);
setQuestions(draft.questions ?? []);
setDraftInfo({ savedAt: draft.savedAt });
toast("Restored an unsaved draft from this browser.");
} catch {
localStorage.removeItem(DRAFT_KEY);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initializing]);
// ── 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 — a generous scroll-margin-top on each question keeps it
// clear of the page's sticky navbar/tab-bar above without needing to
// measure their heights.
const scrollToQuestion = (index) => {
questionRefs.current[index]?.scrollIntoView({ behavior: "smooth", block: "start" });
};
// ── 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;
const { dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
// ── Auto-save unsaved changes to localStorage as a draft ───────────────────
useEffect(() => {
if (initializing) return;
const timer = setTimeout(() => {
if (isDirty) {
const payload = { title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions, savedAt: Date.now() };
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload));
setDraftInfo({ savedAt: payload.savedAt });
} catch {
// localStorage full/unavailable — draft persistence is best-effort only.
}
} else {
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
}
}, 400);
return () => clearTimeout(timer);
}, [initializing, isDirty, title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions, DRAFT_KEY]);
// ── Clear draft — discards the local draft and reverts to the last loaded state ─
const handleClearDraft = () => {
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
const s = toFormState(localAssessment);
setTitle(s.title); setPassingScore(s.passingScore); setTimeLimit(s.timeLimit); setIsRequired(s.isRequired);
setMaxQuestions(s.maxQuestions); setMaxAttempts(s.maxAttempts); setCooldownHours(s.cooldownHours);
setShuffleQuestions(s.shuffleQuestions); setQuestions(s.questions);
setErrors({});
toast("Draft cleared.");
};
// ── 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);
// res is the raw response body: { status, message, data: { data: assessment } }
// (createAssessment() itself unwraps the same object as data?.data?.data to get
// the assessment row — this needs the same two levels, not three).
id = res?.data?.data?.assessment_id;
if (!id) return;
setLocalAssessment((prev) => ({ ...prev, assessment_id: id }));
} else {
await updateAssessment(courseId, id, meta);
}
// A save that keeps none of the previously-loaded question ids would archive the
// entire existing pool in one call — could be a deliberate "start over", but is
// exactly the signature of a stale/incomplete state bug. Require explicit confirmation.
const retainedIds = questions.map((q) => q.question_id).filter(Boolean);
const wipesPool = originalQuestionIdsRef.current.length > 0 && retainedIds.length === 0;
if (wipesPool && !window.confirm(
`This will permanently remove all ${originalQuestionIdsRef.current.length} existing question(s) from this assessment. Continue?`
)) {
return;
}
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id, wipesPool);
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
localStorage.removeItem(DRAFT_KEY);
setDraftInfo(null);
onSaved?.();
};
const handleConfirmSave = async () => {
const { assessmentId, meta } = pendingSaveRef.current ?? {};
if (!assessmentId) return;
await executeSave(assessmentId, meta);
pendingSaveRef.current = null;
setConfirmOpen(false);
};
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="space-y-4">
{/* ── Toolbar ── */}
<div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold flex items-center gap-1.5">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
{localAssessment ? "Modify Assessment" : "Create Assessment"}
</p>
<p className="text-xs text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
</div>
{draftInfo && (
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5 text-sm">
<span className="size-2 rounded-full bg-amber-500" />
Draft saved {new Date(draftInfo.savedAt).toLocaleTimeString()}
</span>
<Button type="button" variant="outline" size="sm" onClick={handleClearDraft}>
<Trash2 className="h-3.5 w-3.5" />
Clear draft
</Button>
</div>
)}
{onCancel && (
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
<X className="h-3.5 w-3.5 mr-1" />
Cancel
</Button>
)}
<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>
{/* ── Split layout ── */}
<div className="flex flex-col lg:flex-row gap-6 items-start">
{/* LEFT — Navigator (desktop only) */}
<div className="hidden lg:block w-64 shrink-0">
<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 w-full">
{initializing ? (
<div className="flex items-center justify-center py-20">
<Spinner className="h-6 w-6" />
</div>
) : (
<div className="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)}
style={{ scrollMarginTop: "calc(var(--navbar-h, 64px) + 180px)" }}
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>
)}
{unsavedChangesDialog}
</div>
);
}
@@ -0,0 +1,470 @@
import { useEffect, useState } from "react";
import {
ClipboardList, NotebookPen,
CheckCircle2, Circle, Users, Activity,
ChevronDown, ChevronUp,
} from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function InfoRow({ label, children }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">{children ?? <span className="italic text-muted-foreground">—</span>}</span>
</div>
);
}
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && (<><h2 className="text-sm font-semibold">{title}</h2><Separator /></>)}
{children}
</div>
);
}
function StatCard({ label, value, color = "default" }) {
const colors = {
default: "bg-card border",
green: "bg-green-500/5 border-green-500/20",
red: "bg-red-500/5 border-red-500/20",
blue: "bg-blue-500/5 border-blue-500/20",
amber: "bg-amber-500/5 border-amber-500/20",
};
return (
<div className={`rounded-lg border p-4 text-center ${colors[color]}`}>
<p className="text-2xl font-bold">{value ?? 0}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</div>
);
}
const TYPE_LABELS = {
multiple_choice: "Multiple Choice",
multi_select: "Multi Select",
true_false: "True / False",
};
function QuestionView({ question, index }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground mt-0.5">
{index + 1}
</span>
<p className="text-sm font-medium leading-snug">
{question.question?.trim() || <span className="italic text-muted-foreground">Untitled</span>}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-xs">{TYPE_LABELS[question.type] ?? question.type}</Badge>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
</span>
</div>
</div>
{question.type === "multi_select" && (() => {
const cnt = (question.options ?? []).filter((o) => o.is_correct).length;
return cnt > 0 ? (
<p className="text-xs font-medium text-blue-600 dark:text-blue-400 pl-8">
Students select {cnt} answer{cnt !== 1 ? "s" : ""}
</p>
) : null;
})()}
<ul className="space-y-1.5 pl-8">
{(question.options ?? []).map((opt, oi) => (
<li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm">
{opt.is_correct ? (
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
) : (
<Circle className="h-4 w-4 text-muted-foreground/40 shrink-0" />
)}
<span className={opt.is_correct ? "font-medium text-green-700 dark:text-green-400" : "text-muted-foreground"}>
{opt.text}
</span>
</li>
))}
</ul>
</div>
);
}
// ─── Completions tab ──────────────────────────────────────────────────────────
function CompletionRow({ row }) {
const { fmtDate, fmtDateTime } = useDateFormat();
const [open, setOpen] = useState(false);
return (
<>
<tr
className="border-b transition-colors hover:bg-muted/40 cursor-pointer"
onClick={() => setOpen((v) => !v)}
>
<td className="px-4 py-3">
<div>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium">{row.full_name ?? "Unknown User"}</p>
{row.deleted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
Deleted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{row.email ?? "—"}</p>
</div>
</td>
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
<td className="px-4 py-3 text-center">
<span className="text-sm font-semibold">{row.best_score}%</span>
</td>
<td className="px-4 py-3 text-center">
{row.passed ? (
<Badge className="bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20">Passed</Badge>
) : (
<Badge variant="outline" className="text-red-600 dark:text-red-400 border-red-500/20">Not yet</Badge>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.latest_at ? fmtDate(row.latest_at) : "—"}
</td>
<td className="px-4 py-3 text-center">
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
</td>
</tr>
{open && (
<tr className="bg-muted/30">
<td colSpan={6} className="px-6 py-3">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Attempt</th>
<th className="text-center py-1 font-medium">Score</th>
<th className="text-center py-1 font-medium">Points</th>
<th className="text-center py-1 font-medium">Result</th>
<th className="text-left py-1 font-medium">Date</th>
</tr>
</thead>
<tbody>
{(row.attempts ?? []).map((a) => (
<tr key={a.attempt_id} className="border-b border-border/50 last:border-0">
<td className="py-1.5 text-muted-foreground">#{a.attempt_number}</td>
<td className="py-1.5 text-center font-semibold">{a.score}%</td>
<td className="py-1.5 text-center text-muted-foreground">{a.earned_points}/{a.total_points}</td>
<td className="py-1.5 text-center">
{a.passed
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
: <span className="text-red-500 font-medium">Fail</span>}
</td>
<td className="py-1.5 text-muted-foreground">{fmtDateTime(a.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
);
}
function CompletionsTab({ completions, loading }) {
if (loading) return <div className="space-y-3">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-12 w-full rounded-lg" />)}</div>;
if (!completions) return (
<div className="rounded-lg border border-dashed bg-card p-12 text-center">
<p className="text-sm text-muted-foreground">No data yet.</p>
</div>
);
const { summary, completions: rows } = completions;
return (
<div className="space-y-5">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<StatCard label="Total Takers" value={summary.total_takers} />
<StatCard label="Passed" value={summary.passed_count} color="green" />
<StatCard label="Failed" value={summary.failed_count} color="red" />
<StatCard label="Pass Rate" value={`${summary.pass_rate}%`} color="blue" />
<StatCard label="Avg Score" value={`${summary.avg_score}%`} />
</div>
{rows.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 text-center">
<p className="text-sm text-muted-foreground">No one has attempted this assessment yet.</p>
</div>
) : (
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Student</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Attempts</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Best Score</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Last Attempt</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row) => <CompletionRow key={row.user_id} row={row} />)}
</tbody>
</table>
</div>
)}
</div>
);
}
// ─── Sessions tab ─────────────────────────────────────────────────────────────
const SESSION_BADGE = {
completed: "bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20",
expired: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
in_progress: "bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20",
};
function fmtDuration(secs) {
if (secs == null) return "—";
if (secs < 60) return `${secs}s`;
const m = Math.floor(secs / 60);
const s = secs % 60;
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
function SessionsTab({ sessions, loading }) {
const { fmtDateTime } = useDateFormat();
if (loading) return <div className="space-y-3">{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10 w-full rounded-lg" />)}</div>;
if (!sessions) return (
<div className="rounded-lg border border-dashed bg-card p-12 text-center">
<p className="text-sm text-muted-foreground">No data yet.</p>
</div>
);
const { summary, sessions: rows } = sessions;
return (
<div className="space-y-5">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total Sessions" value={summary.total} />
<StatCard label="Completed" value={summary.completed} color="green" />
<StatCard label="Expired" value={summary.expired} color="red" />
<StatCard label="In Progress" value={summary.in_progress} color="amber" />
</div>
{rows.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 text-center">
<p className="text-sm text-muted-foreground">No sessions recorded yet.</p>
</div>
) : (
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Student</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Started</th>
<th className="text-left px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Expires At</th>
<th className="text-center px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Time Spent</th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium">{s.full_name ?? "Unknown User"}</p>
{s.deleted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
Deleted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{s.email ?? "—"}</p>
</td>
<td className="px-4 py-3 text-center">
<Badge className={SESSION_BADGE[s.status] ?? ""}>
{s.status === 'in_progress' ? 'In Progress' : s.status.charAt(0).toUpperCase() + s.status.slice(1)}
</Badge>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{fmtDateTime(s.started_at)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{s.expires_at ? fmtDateTime(s.expires_at) : "—"}
</td>
<td className="px-4 py-3 text-center text-sm">{fmtDuration(s.time_spent_seconds)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full rounded-lg" />
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full rounded-lg" />)}
</div>
);
}
// ─── Main ──────────────────────────────────────────────────────────────────────
const TABS = [
{ key: "questions", label: "Questions", icon: ClipboardList },
{ key: "completions", label: "Completions", icon: Users },
{ key: "sessions", label: "Sessions", icon: Activity },
];
export default function AssessmentOverview({ courseId, onModify }) {
const {
fetchAssessmentCompletions, fetchAssessmentSessions,
completions, sessions,
loading,
} = useCourses();
const [localAssessment, setLocalAssessment] = useState(null);
const [initializing, setInitializing] = useState(true);
const [activeTab, setActiveTab] = useState("questions");
useEffect(() => {
(async () => {
setInitializing(true);
try {
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
setLocalAssessment(data?.data?.data ?? null);
} catch (err) {
if (err?.response?.status !== 404) {
toast(err?.response?.data?.message ?? "Could not load assessment.");
}
} finally {
setInitializing(false);
}
})();
}, [courseId]);
// Lazy-load completions/sessions the first time each tab is opened
useEffect(() => {
if (!localAssessment?.assessment_id) return;
if (activeTab === "completions") fetchAssessmentCompletions(courseId, localAssessment.assessment_id);
if (activeTab === "sessions") fetchAssessmentSessions(courseId, localAssessment.assessment_id);
}, [activeTab, localAssessment?.assessment_id]);
const questions = localAssessment?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
<div className="space-y-5">
{/* ── Toolbar ── */}
<div className="flex items-center gap-2 rounded-lg border bg-card px-4 py-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold flex items-center gap-1.5">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
Assessment
</p>
{localAssessment && (
<p className="text-xs text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
)}
</div>
{localAssessment && (
<Button variant="outline" size="sm" onClick={onModify}>
<NotebookPen className="h-4 w-4 mr-2" />
Modify Assessment
</Button>
)}
</div>
{/* ── Sub-tabs ── */}
{localAssessment && (
<div className="flex gap-1 border-b">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors
${activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
)}
{/* ── Content ── */}
{initializing || (loading && !localAssessment) ? (
<LoadingSkeleton />
) : !localAssessment ? (
<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 assessment has been created for this course yet.</p>
<Button size="sm" variant="outline" onClick={onModify}>
<NotebookPen className="h-4 w-4 mr-2" />
Create Assessment
</Button>
</div>
) : activeTab === "questions" ? (
<>
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{localAssessment.title || "Course Assessment"}</InfoRow>
<InfoRow label="Required">
<Badge variant={localAssessment.is_required ? "default" : "secondary"} className="mt-0.5">
{localAssessment.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow>
<InfoRow label="Passing Score">{localAssessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{localAssessment.time_limit_minutes ? `${localAssessment.time_limit_minutes} mins` : "No limit"}
</InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Max Shown per Attempt">
{localAssessment.max_questions ? `${localAssessment.max_questions} (random)` : `All (${questions.length})`}
</InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow>
<InfoRow label="Max Failed Attempts">{localAssessment.max_attempts ?? 3}</InfoRow>
<InfoRow label="Cooldown After Fails">{localAssessment.cooldown_hours ?? 24}h</InfoRow>
</div>
</SectionCard>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">Questions</p>
{questions.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">No questions added yet.</p>
</div>
) : (
questions.map((q, i) => <QuestionView key={q.question_id ?? i} question={q} index={i} />)
)}
</div>
</>
) : activeTab === "completions" ? (
<CompletionsTab completions={completions} loading={loading} />
) : (
<SessionsTab sessions={sessions} loading={loading} />
)}
</div>
);
}
@@ -0,0 +1,31 @@
import { useState } from "react";
import AssessmentOverview from "./AssessmentOverview";
import AssessmentEditor from "./AssessmentEditor";
export default function CourseAssessmentPanel({ courseId }) {
const [mode, setMode] = useState("overview"); // "overview" | "edit"
const [overviewKey, setOverviewKey] = useState(0); // bump to force AssessmentOverview to refetch
const backToOverview = () => {
setOverviewKey((k) => k + 1);
setMode("overview");
};
if (mode === "edit") {
return (
<AssessmentEditor
courseId={courseId}
onSaved={backToOverview}
onCancel={backToOverview}
/>
);
}
return (
<AssessmentOverview
key={overviewKey}
courseId={courseId}
onModify={() => setMode("edit")}
/>
);
}
@@ -72,9 +72,6 @@ export default function CoursesTable() {
};
const rowActions = buildRowActions({
onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`),
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
onEdit: (row) => navigate(`/admin/courses/${row.course_id}/edit`),
onArchive: (row) => setArchiveTarget(row),
@@ -8,8 +8,6 @@ import { useAuth } from "@/contexts/AuthContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import AttachUnitsDialog from "../library/AttachUnitsDialog";
import { Link2 } from "lucide-react";
import { buildDataColumns, columnPinning } from "../../config/courses/units/columns.config";
import { buildToolbarActions } from "../../config/courses/units/toolbar.config";
@@ -22,7 +20,6 @@ import { formatGeneratedBy } from "@/utils/generatedBy.util";
export default function UnitsTable({ courseId, returnTo }) {
const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [attachOpen, setAttachOpen] = useState(false);
const tableRefsRef = useRef({
getFilters: () => [],
@@ -65,33 +62,17 @@ export default function UnitsTable({ courseId, returnTo }) {
onArchive: (row) => setArchiveTarget(row),
}), [courseId]);
const toolbarActions = [
...buildToolbarActions({
fetchUnits: (params) => fetchUnits(courseId, params),
pagination,
exportConfig,
navigate,
courseId,
returnTo,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
}),
// Junction revamp — units live standalone in the library; attach without re-creating
{
key: "attach-existing",
type: "button",
label: "Attach Existing",
icon: <Link2 className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => setAttachOpen(true),
},
];
const handleAttachUnits = async (unitIds) => {
await api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: unitIds });
fetchUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 });
};
const toolbarActions = buildToolbarActions({
fetchUnits: (params) => fetchUnits(courseId, params),
pagination,
exportConfig,
navigate,
courseId,
returnTo,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
@@ -174,15 +155,6 @@ export default function UnitsTable({ courseId, returnTo }) {
loading={loading}
onSuccess={handleArchiveSuccess}
/>
{/* ── Attach existing library units ── */}
<AttachUnitsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedUnitIds={units.map((u) => u.unit_id)}
onAttach={handleAttachUnits}
loading={loading}
/>
</>
);
}