diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx
index 589536d..5acb00d 100644
--- a/src/contexts/AdminCoursesContext.jsx
+++ b/src/contexts/AdminCoursesContext.jsx
@@ -39,6 +39,8 @@ export function CoursesProvider({ children }) {
const [quiz, setQuiz] = useState(null);
const [questions, setQuestions] = useState([]);
const [assessment, setAssessment] = useState(null);
+ const [completions, setCompletions] = useState(null); // { summary, completions[] }
+ const [sessions, setSessions] = useState(null); // { summary, sessions[] }
const [attributes, setAttributes] = useState([]);
const [pagination, setPagination] = useState(PAGINATION_INIT);
const [loading, setLoading] = useState(false);
@@ -847,6 +849,43 @@ export function CoursesProvider({ children }) {
[request],
);
+ // =========================================================================
+ // COMPLETIONS & SESSIONS
+ // =========================================================================
+
+ const fetchQuizCompletions = useCallback(
+ (courseId, unitId, quizId) =>
+ request(async () => {
+ setCompletions(null);
+ const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/completions`);
+ setCompletions(data?.data ?? null);
+ return data;
+ }),
+ [request],
+ );
+
+ const fetchAssessmentCompletions = useCallback(
+ (courseId, assessmentId) =>
+ request(async () => {
+ setCompletions(null);
+ const { data } = await api.get(`${BASE}/${courseId}/assessment/${assessmentId}/completions`);
+ setCompletions(data?.data ?? null);
+ return data;
+ }),
+ [request],
+ );
+
+ const fetchAssessmentSessions = useCallback(
+ (courseId, assessmentId) =>
+ request(async () => {
+ setSessions(null);
+ const { data } = await api.get(`${BASE}/${courseId}/assessment/${assessmentId}/sessions`);
+ setSessions(data?.data ?? null);
+ return data;
+ }),
+ [request],
+ );
+
// =========================================================================
// COURSE PRODUCT & CATEGORIES
// =========================================================================
@@ -1032,6 +1071,12 @@ export function CoursesProvider({ children }) {
fetchLessonPage,
saveLessonPage,
+ // ── completions & sessions ─────────────────────────────────────────────
+ completions, sessions,
+ fetchQuizCompletions,
+ fetchAssessmentCompletions,
+ fetchAssessmentSessions,
+
// ── assessment ─────────────────────────────────────────────────────────
fetchAssessment,
createAssessment,
diff --git a/src/contexts/ClientCoursesContext.jsx b/src/contexts/ClientCoursesContext.jsx
index 7f8bc67..04ab443 100644
--- a/src/contexts/ClientCoursesContext.jsx
+++ b/src/contexts/ClientCoursesContext.jsx
@@ -130,11 +130,34 @@ export function ClientCoursesProvider({ children }) {
}
}, []);
- const submitCourseAssessment = useCallback(async (courseId, assessmentId, answers) => {
+ const startCourseAssessment = useCallback(async (courseId, assessmentId) => {
+ try {
+ const { data } = await api.post(`/client/courses/${courseId}/assessment/${assessmentId}/start`);
+ return data.data ?? null;
+ } catch (err) {
+ toast.error(err?.response?.data?.message ?? "Could not start assessment.");
+ return null;
+ }
+ }, []);
+
+ const saveDraft = useCallback(async (courseId, assessmentId, answers) => {
+ try {
+ await api.patch(`/client/courses/${courseId}/assessment/${assessmentId}/draft`, { answers });
+ } catch { /* silent — draft saves are best-effort */ }
+ }, []);
+
+ const refreshAssessmentSession = useCallback(async (courseId, assessmentId) => {
+ try {
+ const { data } = await api.get(`/client/courses/${courseId}/assessment/${assessmentId}/session`);
+ return data.data ?? null;
+ } catch { return null; }
+ }, []);
+
+ const submitCourseAssessment = useCallback(async (courseId, assessmentId, answers, sessionId) => {
try {
const { data } = await api.post(
`/client/courses/${courseId}/assessment/${assessmentId}/submit`,
- { answers }
+ { answers, ...(sessionId ? { session_id: sessionId } : {}) }
);
return data.data ?? null;
} catch (err) {
@@ -214,6 +237,9 @@ export function ClientCoursesProvider({ children }) {
getLesson,
getUnitQuiz,
getCourseAssessment,
+ startCourseAssessment,
+ saveDraft,
+ refreshAssessmentSession,
submitUnitQuiz,
submitCourseAssessment,
diff --git a/src/contexts/ClientNotificationContext.jsx b/src/contexts/ClientNotificationContext.jsx
index f3b2ce7..e7ae9bd 100644
--- a/src/contexts/ClientNotificationContext.jsx
+++ b/src/contexts/ClientNotificationContext.jsx
@@ -3,7 +3,8 @@ import api from "@/utils/api.util";
const ClientNotificationContext = createContext(null);
-const POLL_INTERVAL = 60_000;
+const POLL_INTERVAL_NORMAL = 60_000;
+const POLL_INTERVAL_FAST = 10_000;
export function useClientNotifications() {
const ctx = useContext(ClientNotificationContext);
@@ -15,7 +16,8 @@ export function ClientNotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const [unseenCount, setUnseenCount] = useState(0);
const [loading, setLoading] = useState(false);
- const intervalRef = useRef(null);
+ const intervalRef = useRef(null);
+ const pollSpeedRef = useRef(POLL_INTERVAL_NORMAL);
const fetchUnseen = useCallback(async () => {
try {
@@ -62,9 +64,27 @@ export function ClientNotificationProvider({ children }) {
}
}, []);
+ const restartPoll = useCallback((interval) => {
+ clearInterval(intervalRef.current);
+ intervalRef.current = setInterval(fetchUnseen, interval);
+ }, [fetchUnseen]);
+
+ // Speed up polling while a student is mid-assessment; restore when done
+ const accelerate = useCallback(() => {
+ if (pollSpeedRef.current === POLL_INTERVAL_FAST) return;
+ pollSpeedRef.current = POLL_INTERVAL_FAST;
+ restartPoll(POLL_INTERVAL_FAST);
+ }, [restartPoll]);
+
+ const decelerate = useCallback(() => {
+ if (pollSpeedRef.current === POLL_INTERVAL_NORMAL) return;
+ pollSpeedRef.current = POLL_INTERVAL_NORMAL;
+ restartPoll(POLL_INTERVAL_NORMAL);
+ }, [restartPoll]);
+
useEffect(() => {
fetchUnseen();
- intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL);
+ intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL_NORMAL);
return () => clearInterval(intervalRef.current);
}, [fetchUnseen]);
@@ -76,6 +96,8 @@ export function ClientNotificationProvider({ children }) {
fetchNotifications,
markSeen,
markAllSeen,
+ accelerate,
+ decelerate,
}}>
{children}
diff --git a/src/modules/admin/components/courses/QuestionEditor.jsx b/src/modules/admin/components/courses/QuestionEditor.jsx
index b39fc3f..c8dca7b 100644
--- a/src/modules/admin/components/courses/QuestionEditor.jsx
+++ b/src/modules/admin/components/courses/QuestionEditor.jsx
@@ -178,12 +178,19 @@ export function QuestionCard({ question, index, onChange, onRemove, error }) {
{/* Options */}
-
- Options
-
- — click circle to mark correct
-
-
+
+
+ Options
+
+ — click circle to mark correct
+
+
+ {question.type === "multi_select" && correctCount > 0 && (
+
+ Students must select {correctCount} answer{correctCount !== 1 ? "s" : ""}
+
+ )}
+
{question.type !== "true_false" && (
({
+ question_id: q.question_id ?? null,
+ question: q.question,
+ type: q.type,
+ points: q.points ?? 1,
+ options: (q.options ?? []).map((o) => ({
+ option_id: o.option_id ?? null,
+ text: o.text,
+ is_correct: o.is_correct,
+ })),
+ })),
+ });
+}
// ── Validation ────────────────────────────────────────────────────────────────
@@ -200,6 +220,15 @@ export default function CourseAssessment() {
const [timeLimit, setTimeLimit] = useState("");
const [isRequired, setIsRequired] = useState(false);
const [maxQuestions, setMaxQuestions] = useState("");
+ const [maxAttempts, setMaxAttempts] = useState(3);
+ const [cooldownHours, setCooldownHours] = useState(24);
+
+ // ── Update confirmation dialog ─────────────────────────────────────────────
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [inProgressCount, setInProgressCount] = useState(0);
+ const [confirmLoading, setConfirmLoading] = useState(false);
+ const pendingSaveRef = useRef(null); // stores the meta+questions payload until confirmed
+ const initialSnapshot = useRef(null);
const questionRefs = useRef([]);
const navItemRefs = useRef([]);
@@ -217,18 +246,17 @@ export default function CourseAssessment() {
// ── Seed ──────────────────────────────────────────────────────────────────
useEffect(() => {
if (!assessment) return;
- setTitle(assessment.title ?? "");
- setPassingScore(assessment.passing_score ?? 70);
- setTimeLimit(assessment.time_limit_minutes ?? "");
- setIsRequired(assessment.is_required === true || assessment.is_required === 1);
- setMaxQuestions(assessment.max_questions ?? "");
- setQuestions(
- (assessment.questions ?? []).map((q) => ({
- ...q,
- _tempId: q.question_id,
- options: q.options ?? [],
- }))
- );
+ const t = assessment.title ?? "";
+ const ps = assessment.passing_score ?? 70;
+ const tl = assessment.time_limit_minutes ?? "";
+ const ir = assessment.is_required === true || assessment.is_required === 1;
+ const mq = assessment.max_questions ?? "";
+ const ma = assessment.max_attempts ?? 3;
+ const ch = assessment.cooldown_hours ?? 24;
+ const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
+ setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
+ setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setQuestions(qs);
+ initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, questions: qs });
}, [assessment]);
// ── Measure sticky header → --assessment-h ────────────────────────────────
@@ -335,46 +363,82 @@ export default function CourseAssessment() {
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, questions }) !== initialSnapshot.current;
+
// ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => {
const errs = validate(questions);
if (Object.keys(errs).length) {
setErrors(errs);
- // Jump to first error
- const firstErr = parseInt(Object.keys(errs)[0], 10);
- jumpTo(firstErr);
+ jumpTo(parseInt(Object.keys(errs)[0], 10));
return;
}
- let assessmentId = assessment?.assessment_id;
+ const assessmentId = assessment?.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,
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);
- assessmentId = res?.data?.data?.data?.assessment_id;
- if (!assessmentId) return;
+ id = res?.data?.data?.data?.assessment_id;
+ if (!id) return;
} else {
- await updateAssessment(courseId, assessmentId, meta);
+ await updateAssessment(courseId, id, meta);
}
for (let i = 0; i < questions.length; i++) {
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
if (q.question_id) {
- await updateAssessmentQuestion(courseId, assessmentId, q.question_id, q);
+ await updateAssessmentQuestion(courseId, id, q.question_id, q);
} else {
- await createAssessmentQuestion(courseId, assessmentId, q);
+ await createAssessmentQuestion(courseId, id, q);
}
}
- // navigate(-1);
+ initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions });
+ };
+
+ const handleConfirmSave = async () => {
+ const { assessmentId, meta } = pendingSaveRef.current ?? {};
+ if (!assessmentId) return;
+ await executeSave(assessmentId, meta);
+ pendingSaveRef.current = null;
+ setConfirmOpen(false);
};
// ── Render ─────────────────────────────────────────────────────────────────
@@ -402,8 +466,8 @@ export default function CourseAssessment() {
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
-
- {loading ? : }
+
+ {(loading || confirmLoading) ? : }
Save Assessment
@@ -491,6 +555,28 @@ export default function CourseAssessment() {
placeholder={`All (${questions.length})`}
/>
+
+
+ Max Failed Attempts{" "}
+ before cooldown
+
+ setMaxAttempts(e.target.value)}
+ />
+
+
+
+ Cooldown{" "}
+ (hours after max fails)
+
+ setCooldownHours(e.target.value)}
+ />
+
@@ -569,6 +655,58 @@ export default function CourseAssessment() {
)}
+
+ {/* ── Update confirmation dialog ── */}
+ {confirmOpen && (
+
+
+
+
+
+
Save Assessment Changes?
+
+ Changes will take effect immediately for all students.
+
+
+
+
+ {inProgressCount > 0 && (
+
+
+
+ {inProgressCount} student{inProgressCount !== 1 ? "s are" : " is"} currently taking this assessment.
+ Saving now will affect their ongoing session.
+
+
+ )}
+
+
+ • New passing score applies to future submissions only.
+ • Changing the time limit does not affect already-started sessions.
+ • Adding or removing questions affects any student not yet on that question.
+
+
+
+ { setConfirmOpen(false); pendingSaveRef.current = null; }}
+ disabled={loading}
+ >
+ Cancel
+
+
+ {loading ? : null}
+ Save Anyway
+
+
+
+
+ )}
);
}
\ No newline at end of file
diff --git a/src/modules/admin/pages/courses/EditCourse.jsx b/src/modules/admin/pages/courses/EditCourse.jsx
index 6f798bf..39a10bb 100644
--- a/src/modules/admin/pages/courses/EditCourse.jsx
+++ b/src/modules/admin/pages/courses/EditCourse.jsx
@@ -56,21 +56,17 @@ const schema = z.object({
// ─── Helpers ──────────────────────────────────────────────────────────────────
const CertBadgeIcon = ({ className }) => (
-
-
-
- {/* short top bar */}
-
- {/* longer middle bar */}
-
- {/* gold bottom bar */}
-
+
-
-
-
+
+
+
+
+
+
+
);
diff --git a/src/modules/admin/pages/courses/ViewAssessment.jsx b/src/modules/admin/pages/courses/ViewAssessment.jsx
index 072a54a..bee4e63 100644
--- a/src/modules/admin/pages/courses/ViewAssessment.jsx
+++ b/src/modules/admin/pages/courses/ViewAssessment.jsx
@@ -1,6 +1,10 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import { ArrowLeft, ClipboardList, NotebookPen, CheckCircle2, Circle } from "lucide-react";
+import {
+ ArrowLeft, ClipboardList, NotebookPen,
+ CheckCircle2, Circle, Users, Activity,
+ ChevronDown, ChevronUp,
+} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
@@ -8,7 +12,6 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
-import { Spinner } from "@/components/ui/spinner";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -24,21 +27,32 @@ function InfoRow({ label, children }) {
function SectionCard({ title, children }) {
return (
- {title && (
- <>
-
{title}
-
- >
- )}
+ {title && (<>{title} >)}
{children}
);
}
+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 (
+
+
{value ?? 0}
+
{label}
+
+ );
+}
+
const TYPE_LABELS = {
multiple_choice: "Multiple Choice",
- multi_select: "Multi Select",
- true_false: "True / False",
+ multi_select: "Multi Select",
+ true_false: "True / False",
};
function QuestionView({ question, index }) {
@@ -54,15 +68,20 @@ function QuestionView({ question, index }) {
-
- {TYPE_LABELS[question.type] ?? question.type}
-
+ {TYPE_LABELS[question.type] ?? question.type}
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
-
+ {question.type === "multi_select" && (() => {
+ const cnt = (question.options ?? []).filter((o) => o.is_correct).length;
+ return cnt > 0 ? (
+
+ Students select {cnt} answer{cnt !== 1 ? "s" : ""}
+
+ ) : null;
+ })()}
{(question.options ?? []).map((opt, oi) => (
@@ -81,6 +100,205 @@ function QuestionView({ question, index }) {
);
}
+// ─── Completions tab ──────────────────────────────────────────────────────────
+
+function CompletionRow({ row }) {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen((v) => !v)}
+ >
+
+
+
{row.full_name}
+
{row.email}
+
+
+ {row.attempt_count}
+
+ {row.best_score}%
+
+
+ {row.passed ? (
+ Passed
+ ) : (
+ Not yet
+ )}
+
+
+ {row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
+
+
+ {open ? : }
+
+
+ {open && (
+
+
+
+
+
+ Attempt
+ Score
+ Points
+ Result
+ Date
+
+
+
+ {(row.attempts ?? []).map((a) => (
+
+ #{a.attempt_number}
+ {a.score}%
+ {a.earned_points}/{a.total_points}
+
+ {a.passed
+ ? Pass
+ : Fail }
+
+ {new Date(a.createdAt).toLocaleString()}
+
+ ))}
+
+
+
+
+ )}
+ >
+ );
+}
+
+function CompletionsTab({ completions, loading }) {
+ if (loading) return {[...Array(3)].map((_, i) => )}
;
+
+ if (!completions) return (
+
+ );
+
+ const { summary, completions: rows } = completions;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {rows.length === 0 ? (
+
+
No one has attempted this assessment yet.
+
+ ) : (
+
+
+
+
+ Student
+ Attempts
+ Best Score
+ Status
+ Last Attempt
+
+
+
+
+ {rows.map((row) => )}
+
+
+
+ )}
+
+ );
+}
+
+// ─── 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 }) {
+ if (loading) return {[...Array(4)].map((_, i) => )}
;
+
+ if (!sessions) return (
+
+ );
+
+ const { summary, sessions: rows } = sessions;
+
+ return (
+
+
+
+
+
+
+
+
+ {rows.length === 0 ? (
+
+
No sessions recorded yet.
+
+ ) : (
+
+
+
+
+ Student
+ Status
+ Started
+ Expires At
+ Time Spent
+
+
+
+ {rows.map((s) => (
+
+
+ {s.full_name}
+ {s.email}
+
+
+
+ {s.status === 'in_progress' ? 'In Progress' : s.status.charAt(0).toUpperCase() + s.status.slice(1)}
+
+
+
+ {new Date(s.started_at).toLocaleString()}
+
+
+ {s.expires_at ? new Date(s.expires_at).toLocaleString() : "—"}
+
+ {fmtDuration(s.time_spent_seconds)}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
function LoadingSkeleton() {
return (
@@ -92,16 +310,38 @@ function LoadingSkeleton() {
// ─── Page ─────────────────────────────────────────────────────────────────────
+const TABS = [
+ { key: "questions", label: "Questions", icon: ClipboardList },
+ { key: "completions", label: "Completions", icon: Users },
+ { key: "sessions", label: "Sessions", icon: Activity },
+];
+
export default function ViewAssessment() {
const navigate = useNavigate();
const { courseId } = useParams();
- const { fetchAssessment, assessment, loading } = useCourses();
+
+ const {
+ fetchAssessment, assessment,
+ fetchAssessmentCompletions, fetchAssessmentSessions,
+ completions, sessions,
+ loading,
+ } = useCourses();
+
+ const [activeTab, setActiveTab] = useState("questions");
useEffect(() => {
fetchAssessment(courseId);
}, [courseId]);
- const questions = assessment?.questions ?? [];
+ // Lazy-load completions/sessions the first time each tab is opened
+ const loadedRef = { completions: false, sessions: false };
+ useEffect(() => {
+ if (!assessment?.assessment_id) return;
+ if (activeTab === "completions") fetchAssessmentCompletions(courseId, assessment.assessment_id);
+ if (activeTab === "sessions") fetchAssessmentSessions(courseId, assessment.assessment_id);
+ }, [activeTab, assessment?.assessment_id]);
+
+ const questions = assessment?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
@@ -129,15 +369,30 @@ export default function ViewAssessment() {
)}
- navigate(`/admin/courses/${courseId}/assessment`)}
- >
+ navigate(`/admin/courses/${courseId}/assessment`)}>
Modify Assessment
+
+ {/* Tabs */}
+ {assessment && (
+
+ {TABS.map(({ key, label, icon: Icon }) => (
+ 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"}`}
+ >
+
+ {label}
+
+ ))}
+
+ )}
@@ -150,18 +405,13 @@ export default function ViewAssessment() {
No assessment has been created for this course yet.
-
navigate(`/admin/courses/${courseId}/assessment`)}
- >
+ navigate(`/admin/courses/${courseId}/assessment`)}>
Create Assessment
- ) : (
+ ) : activeTab === "questions" ? (
<>
- {/* ── Settings ── */}
{assessment.title || "Course Assessment"}
@@ -176,30 +426,29 @@ export default function ViewAssessment() {
{questions.length}
- {assessment.max_questions
- ? `${assessment.max_questions} (random)`
- : `All (${questions.length})`}
+ {assessment.max_questions ? `${assessment.max_questions} (random)` : `All (${questions.length})`}
{totalPoints}
+ {assessment.max_attempts ?? 3}
+ {assessment.cooldown_hours ?? 24}h
- {/* ── Questions ── */}
-
- Questions
-
+
Questions
{questions.length === 0 ? (
) : (
- questions.map((q, i) => (
-
- ))
+ questions.map((q, i) =>
)
)}
>
+ ) : activeTab === "completions" ? (
+
+ ) : (
+
)}
diff --git a/src/modules/admin/pages/courses/units/UnitQuiz.jsx b/src/modules/admin/pages/courses/units/UnitQuiz.jsx
index 6927fc1..3db9ec1 100644
--- a/src/modules/admin/pages/courses/units/UnitQuiz.jsx
+++ b/src/modules/admin/pages/courses/units/UnitQuiz.jsx
@@ -28,6 +28,25 @@ function validate(questions) {
return errors;
}
+// ── Dirty-check snapshot (stable fields only, strips internal _tempId) ────────
+
+function snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) {
+ return JSON.stringify({
+ title, passingScore, isRequired, maxQuestions,
+ 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,
+ })),
+ })),
+ });
+}
+
// ── Jump to input ─────────────────────────────────────────────────────────────
function JumpToInput({ max, onJump }) {
@@ -193,10 +212,11 @@ export default function UnitQuiz() {
const [isRequired, setIsRequired] = useState(false);
const [maxQuestions, setMaxQuestions] = useState("");
- const questionRefs = useRef([]);
- const navItemRefs = useRef([]);
- const navContainerRef = useRef(null);
- const headerRef = useRef(null);
+ const questionRefs = useRef([]);
+ const navItemRefs = useRef([]);
+ const navContainerRef = useRef(null);
+ const headerRef = useRef(null);
+ const initialSnapshot = useRef(null);
const breadcrumbItems = [
{ label: "Home", icon: , to: "/admin" },
@@ -217,17 +237,13 @@ export default function UnitQuiz() {
// ── Seed ──────────────────────────────────────────────────────────────────
useEffect(() => {
if (!quiz) return;
- setTitle(quiz.title ?? "");
- setPassingScore(quiz.passing_score ?? 70);
- setIsRequired(quiz.is_required === true || quiz.is_required === 1);
- setMaxQuestions(quiz.max_questions ?? "");
- setQuestions(
- (quiz.questions ?? []).map((q) => ({
- ...q,
- _tempId: q.question_id,
- options: q.options ?? [],
- }))
- );
+ const t = quiz.title ?? "";
+ const ps = quiz.passing_score ?? 70;
+ const ir = quiz.is_required === true || quiz.is_required === 1;
+ const mq = quiz.max_questions ?? "";
+ const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
+ setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setQuestions(qs);
+ initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, questions: qs });
}, [quiz]);
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
@@ -334,6 +350,11 @@ export default function UnitQuiz() {
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
+ // ── Dirty tracking ─────────────────────────────────────────────────────────
+ const isDirty = initialSnapshot.current === null
+ ? questions.length > 0 // new quiz — enable once they've added a question
+ : snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) !== initialSnapshot.current;
+
// ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => {
const errs = validate(questions);
@@ -370,6 +391,7 @@ export default function UnitQuiz() {
}
}
+ initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, questions });
navigate(-1);
};
@@ -398,7 +420,7 @@ export default function UnitQuiz() {
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
-
+
{loading ? : }
Save Quiz
diff --git a/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx b/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx
index 6e5e817..f32c05d 100644
--- a/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx
+++ b/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx
@@ -1,6 +1,10 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import { ArrowLeft, HelpCircle, NotebookPen, CheckCircle2, Circle } from "lucide-react";
+import {
+ ArrowLeft, HelpCircle, NotebookPen,
+ CheckCircle2, Circle, Users,
+ ChevronDown, ChevronUp,
+} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
@@ -23,21 +27,31 @@ function InfoRow({ label, children }) {
function SectionCard({ title, children }) {
return (
- {title && (
- <>
-
{title}
-
- >
- )}
+ {title && (<>{title} >)}
{children}
);
}
+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",
+ };
+ return (
+
+
{value ?? 0}
+
{label}
+
+ );
+}
+
const TYPE_LABELS = {
multiple_choice: "Multiple Choice",
- multi_select: "Multi Select",
- true_false: "True / False",
+ multi_select: "Multi Select",
+ true_false: "True / False",
};
function QuestionView({ question, index }) {
@@ -53,15 +67,20 @@ function QuestionView({ question, index }) {
-
- {TYPE_LABELS[question.type] ?? question.type}
-
+ {TYPE_LABELS[question.type] ?? question.type}
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
-
+ {question.type === "multi_select" && (() => {
+ const cnt = (question.options ?? []).filter((o) => o.is_correct).length;
+ return cnt > 0 ? (
+
+ Students select {cnt} answer{cnt !== 1 ? "s" : ""}
+
+ ) : null;
+ })()}