mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
change things
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user