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.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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}>← 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user