mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
perform test #1
test to courses Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user