Files
rootnode-academy/frontend/src/admin/LearningView.jsx
T
ced4568 7e5f357b6f feat(admin): add instructor learning analytics and assessment management
New /api/admin routes: cohort learning summary (completion by stage, active learners),
per-student learning detail (attempts, hint usage, assessment history, capstone state),
scenario analytics (completion rate, avg attempts, failure/hint frequency, avg time), and a
needs-attention view (repeated failures, hints used but still stuck, inactivity, an expired
assessment - deliberately instructional wording, not punitive, with an explicit test asserting
that). Assessment definition CRUD (admin/owner only), cohort assignment and session view/cancel
(any instructor role, cohort-scoped for a plain instructor via the existing Phase 3A RBAC
helpers - no new authorization primitives needed).

New Learning and Assessments tabs in the admin portal, visible to every instructor role;
definition-authoring controls are shown but the server 403s a create/edit from a non-admin/
owner role, same pattern CohortsView already uses for admin-only controls.
2026-08-18 14:35:59 -05:00

161 lines
7.0 KiB
React

import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Instructor learning analytics — cohort summary, "needs attention," per-scenario analytics,
// and a student drill-down. Deliberately no leaderboards or scoring language — this is
// instructional visibility, not a ranking (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §13).
export default function LearningView() {
const [cohorts, setCohorts] = useState(null)
const [cohortId, setCohortId] = useState(null)
const [summary, setSummary] = useState(null)
const [needsAttention, setNeedsAttention] = useState(null)
const [scenarioStats, setScenarioStats] = useState(null)
const [selectedStudent, setSelectedStudent] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.cohorts().then((c) => { setCohorts(c); if (c[0]) setCohortId(c[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
const reload = useCallback(() => {
if (!cohortId) return
adminApi.cohortLearning(cohortId).then(setSummary).catch((e) => setError(e.message))
adminApi.needsAttention(cohortId).then(setNeedsAttention).catch((e) => setError(e.message))
adminApi.scenarioAnalytics(cohortId).then(setScenarioStats).catch((e) => setError(e.message))
}, [cohortId])
useEffect(reload, [reload])
if (selectedStudent) return <StudentLearningDetail studentId={selectedStudent} onBack={() => setSelectedStudent(null)} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<label className={styles.label}>Cohort</label>
<select className={styles.select} value={cohortId || ''} onChange={(e) => setCohortId(e.target.value)}>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select>
</div>
{summary && (
<div className={styles.card}>
<h2>Cohort Summary</h2>
<div className={styles.statGrid}>
<div className={styles.statCard}><div className={styles.statValue}>{summary.student_count}</div><div className={styles.statLabel}>Students</div></div>
<div className={styles.statCard}><div className={styles.statValue}>{summary.active_learners}</div><div className={styles.statLabel}>Active (7d)</div></div>
<div className={styles.statCard}><div className={styles.statValue}>{summary.overall_completion_pct}%</div><div className={styles.statLabel}>Avg. Completion</div></div>
</div>
<table>
<thead><tr><th>Stage</th><th>Students Complete</th></tr></thead>
<tbody>
{summary.stages.map((s) => (
<tr key={s.id}><td>{s.name}</td><td>{s.completedBy}/{summary.student_count} ({s.pct}%)</td></tr>
))}
</tbody>
</table>
</div>
)}
<div className={styles.card}>
<h2>Needs Attention</h2>
{!needsAttention ? <p className={styles.muted}>Loading…</p> : needsAttention.length === 0 ? <p className={styles.muted}>No signals right now.</p> : (
<table>
<thead><tr><th>Student</th><th>Signal</th><th>Detail</th><th /></tr></thead>
<tbody>
{needsAttention.map((s, i) => (
<tr key={i}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${styles.pillWarn}`}>{s.label}</span></td>
<td className={styles.muted}>{s.detail}</td>
<td><button className={styles.linkBtn} onClick={() => setSelectedStudent(s.student_id)}>View</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h2>Scenario Analytics</h2>
{!scenarioStats ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Scenario</th><th>Completions</th><th>Avg Attempts</th><th>Failures</th><th>Hints Used</th><th>Avg Time</th></tr></thead>
<tbody>
{scenarioStats.map((s) => (
<tr key={s.scenario_id}>
<td>{s.scenario_id}</td><td>{s.completions}</td><td>{s.avg_attempts}</td>
<td>{s.total_failures}</td><td>{s.total_hints_used}</td>
<td>{s.avg_duration_seconds ? `${Math.round(s.avg_duration_seconds / 60)}m` : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function StudentLearningDetail({ studentId, onBack }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.studentLearning(studentId).then(setData).catch((e) => setError(e.message)) }, [studentId])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!data) return <p className={styles.muted}>Loading…</p>
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back</button>
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
{data.curriculum && (
<div className={styles.card}>
<h3>Curriculum ({data.curriculum.percent_complete}%)</h3>
{data.curriculum.stages.map((s) => (
<div key={s.id} style={{ fontSize: '0.85rem', padding: '0.2rem 0' }}>
{s.complete ? '✅' : s.locked ? '🔒' : '🟡'} {s.name} — {s.completed}/{s.total}
</div>
))}
</div>
)}
<div className={styles.card}>
<h3>Attempts</h3>
<table>
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints (max level)</th><th>Duration</th></tr></thead>
<tbody>
{data.attempts.map((a, i) => (
<tr key={i}>
<td>{a.scenario_id}</td>
<td><span className={`${styles.pill} ${a.status === 'completed' ? styles.pillOk : styles.pillWarn}`}>{a.status}</span></td>
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
<td>{a.hint_count} ({a.highest_hint_level})</td>
<td>{a.duration_seconds ? `${Math.round(a.duration_seconds / 60)}m` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.card}>
<h3>Assessment History</h3>
{data.assessment_sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Assessment</th><th>Status</th><th>Result</th></tr></thead>
<tbody>
{data.assessment_sessions.map((s) => (
<tr key={s.assessment_session_id}>
<td>{s.title}</td>
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}