mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
458 lines
20 KiB
React
458 lines
20 KiB
React
import { useEffect, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import {
|
|
ArrowLeft, ClipboardList, NotebookPen,
|
|
CheckCircle2, Circle, Users, Activity,
|
|
ChevronDown, ChevronUp,
|
|
} from "lucide-react";
|
|
|
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
|
import { PageMeta } from "@/contexts/MetadataContext";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function InfoRow({ label, children }) {
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
|
<span className="text-sm font-medium">{children ?? <span className="italic text-muted-foreground">—</span>}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionCard({ title, children }) {
|
|
return (
|
|
<div className="rounded-lg border bg-card p-5 space-y-4">
|
|
{title && (<><h2 className="text-sm font-semibold">{title}</h2><Separator /></>)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, color = "default" }) {
|
|
const colors = {
|
|
default: "bg-card border",
|
|
green: "bg-green-500/5 border-green-500/20",
|
|
red: "bg-red-500/5 border-red-500/20",
|
|
blue: "bg-blue-500/5 border-blue-500/20",
|
|
amber: "bg-amber-500/5 border-amber-500/20",
|
|
};
|
|
return (
|
|
<div className={`rounded-lg border p-4 text-center ${colors[color]}`}>
|
|
<p className="text-2xl font-bold">{value ?? 0}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const TYPE_LABELS = {
|
|
multiple_choice: "Multiple Choice",
|
|
multi_select: "Multi Select",
|
|
true_false: "True / False",
|
|
};
|
|
|
|
function QuestionView({ question, index }) {
|
|
return (
|
|
<div className="rounded-lg border bg-card p-5 space-y-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-start gap-2 min-w-0">
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground mt-0.5">
|
|
{index + 1}
|
|
</span>
|
|
<p className="text-sm font-medium leading-snug">
|
|
{question.question?.trim() || <span className="italic text-muted-foreground">Untitled</span>}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Badge variant="outline" className="text-xs">{TYPE_LABELS[question.type] ?? question.type}</Badge>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{question.type === "multi_select" && (() => {
|
|
const cnt = (question.options ?? []).filter((o) => o.is_correct).length;
|
|
return cnt > 0 ? (
|
|
<p className="text-xs font-medium text-blue-600 dark:text-blue-400 pl-8">
|
|
Students select {cnt} answer{cnt !== 1 ? "s" : ""}
|
|
</p>
|
|
) : null;
|
|
})()}
|
|
<ul className="space-y-1.5 pl-8">
|
|
{(question.options ?? []).map((opt, oi) => (
|
|
<li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm">
|
|
{opt.is_correct ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
|
) : (
|
|
<Circle className="h-4 w-4 text-muted-foreground/40 shrink-0" />
|
|
)}
|
|
<span className={opt.is_correct ? "font-medium text-green-700 dark:text-green-400" : "text-muted-foreground"}>
|
|
{opt.text}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Completions tab ──────────────────────────────────────────────────────────
|
|
|
|
function CompletionRow({ row }) {
|
|
const [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">
|
|
<Skeleton className="h-32 w-full rounded-lg" />
|
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full rounded-lg" />)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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,
|
|
fetchAssessmentCompletions, fetchAssessmentSessions,
|
|
completions, sessions,
|
|
loading,
|
|
} = useCourses();
|
|
|
|
const [activeTab, setActiveTab] = useState("questions");
|
|
|
|
useEffect(() => {
|
|
fetchAssessment(courseId);
|
|
}, [courseId]);
|
|
|
|
// 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 (
|
|
<div className="flex flex-col min-h-screen bg-muted/60">
|
|
<PageMeta title="View Assessment - STARR" />
|
|
|
|
{/* ── Header ── */}
|
|
<div
|
|
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
|
style={{ top: "var(--navbar-h)" }}
|
|
>
|
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
|
<div className="flex items-center gap-3 py-3">
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
|
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
|
View Assessment
|
|
</h1>
|
|
{assessment && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<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>
|
|
|
|
{/* ── Content ── */}
|
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
|
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
|
{loading && !assessment ? (
|
|
<LoadingSkeleton />
|
|
) : !assessment ? (
|
|
<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`)}>
|
|
<NotebookPen className="h-4 w-4 mr-2" />
|
|
Create Assessment
|
|
</Button>
|
|
</div>
|
|
) : activeTab === "questions" ? (
|
|
<>
|
|
<SectionCard title="Settings">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow>
|
|
<InfoRow label="Required">
|
|
<Badge variant={assessment.is_required ? "default" : "secondary"} className="mt-0.5">
|
|
{assessment.is_required ? "Required" : "Optional"}
|
|
</Badge>
|
|
</InfoRow>
|
|
<InfoRow label="Passing Score">{assessment.passing_score ?? 70}%</InfoRow>
|
|
<InfoRow label="Time Limit">
|
|
{assessment.time_limit_minutes ? `${assessment.time_limit_minutes} mins` : "No limit"}
|
|
</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})`}
|
|
</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>
|
|
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">Questions</p>
|
|
{questions.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center">
|
|
<p className="text-sm text-muted-foreground">No questions added yet.</p>
|
|
</div>
|
|
) : (
|
|
questions.map((q, i) => <QuestionView key={q.question_id ?? i} question={q} index={i} />)
|
|
)}
|
|
</div>
|
|
</>
|
|
) : activeTab === "completions" ? (
|
|
<CompletionsTab completions={completions} loading={loading} />
|
|
) : (
|
|
<SessionsTab sessions={sessions} loading={loading} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|