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:
@@ -6,16 +6,22 @@ import StudentsView from './StudentsView'
|
||||
import InstructorsView from './InstructorsView'
|
||||
import AuditView from './AuditView'
|
||||
import AccountView from './AccountView'
|
||||
import LearningView from './LearningView'
|
||||
import AssessmentsView from './AssessmentsView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||
// separate rn_instructor session cookie, separate login). A student session cannot reach any
|
||||
// of these views or their underlying /api/admin/* routes. Per-person accounts replace Phase
|
||||
// 2B's single shared ADMIN_PASSWORD — see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
|
||||
// Learning/Assessments (Phase 3B) are visible to every instructor role — cohort scoping for a
|
||||
// plain 'instructor' happens server-side per route, not by hiding the whole tab.
|
||||
const NAV = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
||||
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
|
||||
]
|
||||
@@ -53,6 +59,8 @@ export default function AdminPortal() {
|
||||
{view === 'dashboard' && <AdminDashboard />}
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
||||
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
|
||||
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
|
||||
{view === 'account' && <AccountView instructor={instructor} />}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Exam and Capstone definition/session management. Definition authoring (create/edit) is
|
||||
// admin/owner only server-side (RBAC) — a plain instructor can still assign an existing
|
||||
// definition to their own cohort and view/cancel sessions within it, so the form below is
|
||||
// always shown but the server will 403 a create/edit attempt from an instructor role; this
|
||||
// mirrors CohortsView's approach of not pre-emptively hiding every control, since assessments
|
||||
// are viewed by all roles and only definition-authoring is restricted.
|
||||
export default function AssessmentsView({ role }) {
|
||||
const [assessments, setAssessments] = useState(null)
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canAuthor = role === 'owner' || role === 'admin'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.assessments().then(setAssessments).catch((e) => setError(e.message))
|
||||
adminApi.cohorts().then(setCohorts).catch(() => {})
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <AssessmentDetail definition={selected} cohorts={cohorts} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create assessment</h2>
|
||||
<CreateAssessmentForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Exams & Capstones</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Type</th><th>Duration</th><th>Hints</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{assessments.map((a) => (
|
||||
<tr key={a.assessment_definition_id}>
|
||||
<td>{a.title}</td>
|
||||
<td><span className={styles.pill}>{a.type}</span></td>
|
||||
<td>{a.duration_minutes} min</td>
|
||||
<td>{a.hint_policy}</td>
|
||||
<td><span className={`${styles.pill} ${a.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{a.status}</span></td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(a)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAssessmentForm({ onCreated }) {
|
||||
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const pool_spec = JSON.parse(form.pool_spec || '{"required":[],"random":[]}')
|
||||
await adminApi.createAssessment({ ...form, duration_minutes: Number(form.duration_minutes), pool_spec })
|
||||
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message?.includes('JSON') ? 'pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>ID (slug)</label>
|
||||
<input className={styles.input} value={form.id} onChange={(e) => setForm({ ...form, id: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Title</label>
|
||||
<input className={styles.input} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Type</label>
|
||||
<select className={styles.select} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="exam">exam</option><option value="capstone">capstone</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Duration (min)</label>
|
||||
<input className={styles.input} type="number" value={form.duration_minutes} onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Hint policy</label>
|
||||
<select className={styles.select} value={form.hint_policy} onChange={(e) => setForm({ ...form, hint_policy: e.target.value })}>
|
||||
<option value="disabled">disabled</option><option value="flagged">flagged</option><option value="free">free</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label className={styles.label}>Pool spec (JSON)</label>
|
||||
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
|
||||
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
|
||||
value={form.pool_spec} onChange={(e) => setForm({ ...form, pool_spec: e.target.value })} required />
|
||||
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AssessmentDetail({ definition, cohorts, onBack }) {
|
||||
const [sessions, setSessions] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [cohortId, setCohortId] = useState(cohorts?.[0]?.cohort_id || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.assessmentSessions(definition.assessment_definition_id).then(setSessions).catch((e) => setError(e.message))
|
||||
}, [definition])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const assign = async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.assignAssessment(definition.assessment_definition_id, cohortId); setNotice('Assigned.') }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const cancelSession = async (sessionId) => {
|
||||
if (!window.confirm('Cancel this session?')) return
|
||||
try { await adminApi.cancelAssessmentSession(sessionId); reload() } catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to assessments</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{definition.title} <span className={styles.muted}>({definition.type})</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Assign to cohort</h3>
|
||||
<div className={styles.formRow}>
|
||||
<div><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>
|
||||
<div><button className={styles.btn} disabled={busy || !cohortId} onClick={assign}>Assign</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Sessions</h3>
|
||||
{!sessions ? <p className={styles.muted}>Loading…</p> : sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Status</th><th>Started</th><th>Result</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : s.status === 'expired' ? styles.pillErr : styles.pillWarn}`}>{s.status}</span></td>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
<td>{['not_started', 'in_progress'].includes(s.status) && <button className={styles.linkBtn} onClick={() => cancelSession(s.assessment_session_id)}>Cancel</button>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -80,4 +80,18 @@ export const adminApi = {
|
||||
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
|
||||
|
||||
auditEvents: () => get('/api/admin/audit-events'),
|
||||
|
||||
// ── Phase 3B: learning analytics ────────────────────────────────────────────────────────
|
||||
cohortLearning: (id) => get(`/api/admin/cohorts/${id}/learning`),
|
||||
studentLearning: (id) => get(`/api/admin/students/${id}/learning`),
|
||||
scenarioAnalytics: (cohortId) => get(`/api/admin/scenario-analytics${cohortId ? `?cohort_id=${cohortId}` : ''}`),
|
||||
needsAttention: (cohortId) => get(`/api/admin/cohorts/${cohortId}/needs-attention`),
|
||||
|
||||
// ── Phase 3B: assessment (exam + capstone) management ───────────────────────────────────
|
||||
assessments: () => get('/api/admin/assessments'),
|
||||
createAssessment: (fields) => post('/api/admin/assessments', fields),
|
||||
updateAssessment: (id, fields) => patch(`/api/admin/assessments/${id}`, fields),
|
||||
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
|
||||
assessmentSessions: (id) => get(`/api/admin/assessments/${id}/sessions`),
|
||||
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user