perform test #1

test to courses

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-24 12:49:20 +08:00
parent fbef7cb6e6
commit 93c3c688ca
26 changed files with 2141 additions and 452 deletions
@@ -178,12 +178,19 @@ export function QuestionCard({ question, index, onChange, onRemove, error }) {
{/* Options */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Options
<span className="ml-1 normal-case text-muted-foreground/60">
— click circle to mark correct
</span>
</Label>
<div className="space-y-0.5">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Options
<span className="ml-1 normal-case text-muted-foreground/60">
— click circle to mark correct
</span>
</Label>
{question.type === "multi_select" && correctCount > 0 && (
<p className="text-xs text-blue-600 dark:text-blue-400">
Students must select {correctCount} answer{correctCount !== 1 ? "s" : ""}
</p>
)}
</div>
{question.type !== "true_false" && (
<Button
type="button"
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown } from "lucide-react";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -12,6 +12,26 @@ import { Spinner } from "@/components/ui/spinner";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { QuestionCard, makeQuestion } from "../../components/courses/QuestionEditor";
import api from "@/utils/api.util";
// ── Dirty-check snapshot ──────────────────────────────────────────────────────
function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) {
return JSON.stringify({
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours,
questions: questions.map((q) => ({
question_id: q.question_id ?? null,
question: q.question,
type: q.type,
points: q.points ?? 1,
options: (q.options ?? []).map((o) => ({
option_id: o.option_id ?? null,
text: o.text,
is_correct: o.is_correct,
})),
})),
});
}
// ── Validation ────────────────────────────────────────────────────────────────
@@ -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" : ""}
</p>
</div>
<Button onClick={handleSave} disabled={loading}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
<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>
@@ -491,6 +555,28 @@ export default function CourseAssessment() {
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="flex items-center gap-3">
@@ -569,6 +655,58 @@ export default function CourseAssessment() {
)}
</div>
</div>
{/* ── Update confirmation dialog ── */}
{confirmOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-background rounded-xl border shadow-xl w-full max-w-md p-6 space-y-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<AlertTriangle className="h-5 w-5 text-amber-500" />
</div>
<div>
<h2 className="text-base font-semibold">Save Assessment Changes?</h2>
<p className="text-sm text-muted-foreground mt-1">
Changes will take effect immediately for all students.
</p>
</div>
</div>
{inProgressCount > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<p className="text-sm text-amber-700 dark:text-amber-400 font-medium">
{inProgressCount} student{inProgressCount !== 1 ? "s are" : " is"} currently taking this assessment.
Saving now will affect their ongoing session.
</p>
</div>
)}
<ul className="text-sm text-muted-foreground space-y-1 pl-1">
<li>• New passing score applies to future submissions only.</li>
<li>• Changing the time limit does not affect already-started sessions.</li>
<li>• Adding or removing questions affects any student not yet on that question.</li>
</ul>
<div className="flex gap-3 justify-end pt-1">
<Button
variant="outline"
onClick={() => { setConfirmOpen(false); pendingSaveRef.current = null; }}
disabled={loading}
>
Cancel
</Button>
<Button
onClick={handleConfirmSave}
disabled={loading}
>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : null}
Save Anyway
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+8 -12
View File
@@ -56,21 +56,17 @@ const schema = z.object({
// ─── Helpers ──────────────────────────────────────────────────────────────────
const CertBadgeIcon = ({ className }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="120" rx="26" fill="url(#ec-cert-grad)" />
<rect width="120" height="120" rx="26" fill="url(#cd-cert-grad)" />
{/* short top bar */}
<rect x="30" y="32" width="60" height="16" rx="8" fill="white" fillOpacity="0.95" />
{/* longer middle bar */}
<rect x="22" y="56" width="76" height="16" rx="8" fill="white" fillOpacity="0.80" />
{/* gold bottom bar */}
<rect x="19" y="80" width="84" height="14" rx="8" fill="#D4A017" />
<svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<defs>
<linearGradient id="ec-cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#8B9FEE" />
<stop offset="1" stopColor="#4F6FD4" />
<linearGradient id="prism-ec" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#5061E6"/>
</linearGradient>
</defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-ec)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg>
);
@@ -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 (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && (
<>
<h2 className="text-sm font-semibold">{title}</h2>
<Separator />
</>
)}
{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",
multi_select: "Multi Select",
true_false: "True / False",
};
function QuestionView({ question, index }) {
@@ -54,15 +68,20 @@ function QuestionView({ question, index }) {
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[question.type] ?? question.type}
</Badge>
<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">
@@ -81,6 +100,205 @@ function QuestionView({ question, index }) {
);
}
// ─── Completions tab ──────────────────────────────────────────────────────────
function CompletionRow({ row }) {
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>
<p className="text-sm font-medium">{row.full_name}</p>
<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 ? new Date(row.latest_at).toLocaleDateString() : "—"}
</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">{new Date(a.createdAt).toLocaleString()}</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 }) {
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">
<p className="text-sm font-medium">{s.full_name}</p>
<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">
{new Date(s.started_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{s.expires_at ? new Date(s.expires_at).toLocaleString() : "—"}
</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">
@@ -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() {
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
>
<Button variant="outline" size="sm" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}>
<NotebookPen className="h-4 w-4 mr-2" />
Modify Assessment
</Button>
</div>
{/* Tabs */}
{assessment && (
<div className="flex gap-1 pb-0 -mb-px">
{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>
)}
</div>
</div>
@@ -150,18 +405,13 @@ export default function ViewAssessment() {
<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={() => navigate(`/admin/courses/${courseId}/assessment`)}
>
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}>
<NotebookPen className="h-4 w-4 mr-2" />
Create Assessment
</Button>
</div>
) : (
) : activeTab === "questions" ? (
<>
{/* ── Settings ── */}
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow>
@@ -176,30 +426,29 @@ export default function ViewAssessment() {
</InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Max Shown per Attempt">
{assessment.max_questions
? `${assessment.max_questions} (random)`
: `All (${questions.length})`}
{assessment.max_questions ? `${assessment.max_questions} (random)` : `All (${questions.length})`}
</InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow>
<InfoRow label="Max Failed Attempts">{assessment.max_attempts ?? 3}</InfoRow>
<InfoRow label="Cooldown After Fails">{assessment.cooldown_hours ?? 24}h</InfoRow>
</div>
</SectionCard>
{/* ── Questions ── */}
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">
Questions
</p>
<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} />
))
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>
</div>
@@ -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: <House className="size-4" />, 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" : ""}
</p>
</div>
<Button onClick={handleSave} disabled={loading}>
<Button onClick={handleSave} disabled={loading || !isDirty}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Save className="h-4 w-4 mr-2" />}
Save Quiz
</Button>
@@ -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 (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && (
<>
<h2 className="text-sm font-semibold">{title}</h2>
<Separator />
</>
)}
{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",
};
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",
multi_select: "Multi Select",
true_false: "True / False",
};
function QuestionView({ question, index }) {
@@ -53,15 +67,20 @@ function QuestionView({ question, index }) {
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[question.type] ?? question.type}
</Badge>
<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">
@@ -80,6 +99,123 @@ function QuestionView({ question, index }) {
);
}
// ─── Completions tab ──────────────────────────────────────────────────────────
function CompletionRow({ row }) {
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>
<p className="text-sm font-medium">{row.full_name}</p>
<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 ? new Date(row.latest_at).toLocaleDateString() : "—"}
</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">{new Date(a.createdAt).toLocaleString()}</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-4 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" />
</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 quiz 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>
);
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
@@ -91,21 +227,38 @@ function LoadingSkeleton() {
// ─── Page ─────────────────────────────────────────────────────────────────────
const TABS = [
{ key: "questions", label: "Questions", icon: HelpCircle },
{ key: "completions", label: "Completions", icon: Users },
];
export default function ViewUnitQuiz() {
const navigate = useNavigate();
const { courseId, unitId } = useParams();
const { fetchQuiz, quiz, loading } = useCourses();
const {
fetchQuiz, quiz,
fetchQuizCompletions, completions,
loading,
} = useCourses();
const [activeTab, setActiveTab] = useState("questions");
useEffect(() => {
fetchQuiz(courseId, unitId);
}, [courseId, unitId]);
const questions = quiz?.questions ?? [];
useEffect(() => {
if (!quiz?.quiz_id) return;
if (activeTab === "completions") fetchQuizCompletions(courseId, unitId, quiz.quiz_id);
}, [activeTab, quiz?.quiz_id]);
const questions = quiz?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={quiz ? `${quiz.title ?? 'Quiz'} – View - STARR` : undefined} />
<PageMeta title={quiz ? `${quiz.title ?? "Quiz"} – View - STARR` : undefined} />
{/* ── Header ── */}
<div
@@ -137,6 +290,25 @@ export default function ViewUnitQuiz() {
Modify Quiz
</Button>
</div>
{/* Tabs */}
{quiz && (
<div className="flex gap-1 pb-0 -mb-px">
{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>
)}
</div>
</div>
@@ -149,18 +321,13 @@ export default function ViewUnitQuiz() {
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<HelpCircle className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
>
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}>
<NotebookPen className="h-4 w-4 mr-2" />
Create Quiz
</Button>
</div>
) : (
) : activeTab === "questions" ? (
<>
{/* ── Settings ── */}
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{quiz.title || "Unit Quiz"}</InfoRow>
@@ -171,31 +338,26 @@ export default function ViewUnitQuiz() {
</InfoRow>
<InfoRow label="Passing Score">{quiz.passing_score ?? 70}%</InfoRow>
<InfoRow label="Max Shown per Attempt">
{quiz.max_questions
? `${quiz.max_questions} (random)`
: `All (${questions.length})`}
{quiz.max_questions ? `${quiz.max_questions} (random)` : `All (${questions.length})`}
</InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow>
</div>
</SectionCard>
{/* ── Questions ── */}
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">
Questions
</p>
<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} />
))
questions.map((q, i) => <QuestionView key={q.question_id ?? i} question={q} index={i} />)
)}
</div>
</>
) : (
<CompletionsTab completions={completions} loading={loading} />
)}
</div>
</div>
@@ -76,7 +76,7 @@ function RequirementCard({ req }) {
{req.link_url && (
<MetaRow icon={Globe} label="URL">
<a
href={req.link_url}
href={/^https?:\/\//i.test(req.link_url) ? req.link_url : `https://${req.link_url}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity"
@@ -55,7 +55,7 @@ export default function CreateTask() {
requirements: form.requirements,
});
if (created) navigate(`/admin/tasks/${taskListId}/tasks/${created.task_id}`);
if (created) navigate(`/admin/taskList/${taskListId}/tasks/${created.task_id}/view`);
};
return (
@@ -71,7 +71,7 @@ function RequirementCard({ req }) {
{req.link_url && (
<MetaRow icon={Globe} label="URL">
<a
href={req.link_url}
href={/^https?:\/\//i.test(req.link_url) ? req.link_url : `https://${req.link_url}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline underline-offset-2 truncate block max-w-[240px] hover:opacity-80 transition-opacity"