feat(admin): Programs area, enrollment management, student enrolled-programs view

- admin-programs-routes.js: program CRUD (admin/owner authoring, all roles can view — matches
  assessment_definitions' convention), scoped enrollment listing, manual enroll, activate/
  cancel (via rbac.js's requireEnrollmentAccess).
- ProgramsView.jsx: cohort-picker-free program list/create, per-program detail with
  enrollment table and a manual-enroll form.
- StudentsView.jsx: student detail gained an 'Enrolled programs' card.
- AdminPortal.jsx: new Programs nav entry.
This commit is contained in:
2026-08-25 12:08:29 -05:00
parent 611e1ac4ba
commit 17160da89e
5 changed files with 422 additions and 0 deletions
+3
View File
@@ -9,6 +9,7 @@ import AccountView from './AccountView'
import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView'
import ProgramsView from './ProgramsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
@@ -21,6 +22,7 @@ const NAV = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'programs', label: 'Programs' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
@@ -61,6 +63,7 @@ export default function AdminPortal() {
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'programs' && <ProgramsView role={instructor.role} />}
{view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Phase 5B §11 — Programs area. Authoring (create/edit/activate-deactivate) is admin/owner
// only, matching AssessmentsView's "viewed by all roles, only authoring restricted" pattern —
// canAuthor gates the create form and the activate/deactivate toggle only, not the list itself.
export default function ProgramsView({ role }) {
const [programs, setPrograms] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canAuthor = role === 'owner' || role === 'admin'
const reload = useCallback(() => {
adminApi.programs().then(setPrograms).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <ProgramDetail programId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{canAuthor && (
<div className={styles.card}>
<h2>Create program</h2>
<CreateProgramForm onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h2>Programs</h2>
{!programs ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Name</th><th>Family</th><th>Delivery</th><th>Enrollments</th><th>Status</th><th /></tr></thead>
<tbody>
{programs.map((p) => (
<tr key={p.program_id}>
<td>{p.name}</td>
<td><span className={styles.pill}>{p.family}</span></td>
<td className={styles.muted}>{p.delivery_mode.replace('_', ' ')}</td>
<td>{p.enrollment_count}</td>
<td><span className={`${styles.pill} ${p.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{p.status}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelected(p.program_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateProgramForm({ onCreated }) {
const [form, setForm] = useState({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createProgram(form)
setForm({ program_id: '', name: '', family: 'root', delivery_mode: 'self_paced', description: '' })
onCreated()
} catch (e) { setError(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}>Program ID (slug)</label>
<input className={styles.input} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required /></div>
<div><label className={styles.label}>Name</label>
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
<div><label className={styles.label}>Family</label>
<select className={styles.select} value={form.family} onChange={(e) => setForm({ ...form, family: e.target.value })}>
<option value="init">init_ (ages ~5-10)</option>
<option value="compile">compile_ (ages ~11-14)</option>
<option value="root">root_ (ages ~15-18+)</option>
</select></div>
<div><label className={styles.label}>Delivery mode</label>
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
<option value="self_paced">self-paced</option>
<option value="cohort">cohort</option>
<option value="hybrid">hybrid</option>
</select></div>
</div>
<label className={styles.label}>Description</label>
<input className={styles.input} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<p className={styles.muted} style={{ marginTop: '0.5rem' }}>
New programs default to curriculum_path 'rootnode-path' — the only curriculum content that exists today. A program for init_/compile_ can be created now and wired to real content later without a schema change.
</p>
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
</form>
)
}
function ProgramDetail({ programId, canAuthor, onBack }) {
const [program, setProgram] = useState(null)
const [enrollments, setEnrollments] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.program(programId).then(setProgram).catch((e) => setError(e.message))
adminApi.programEnrollments(programId).then(setEnrollments).catch(() => {})
}, [programId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to programs</button>
{!program ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{program.name} <span className={`${styles.pill} ${program.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{program.status}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Overview</h3>
<p className={styles.muted}>{program.description || 'No description.'}</p>
<p>Family: <span className={styles.pill}>{program.family}</span> — Delivery: {program.delivery_mode.replace('_', ' ')} — Curriculum: {program.curriculum_path} (v{program.version})</p>
<p className={styles.muted}>
Enrollments — pending: {program.enrollment_counts.pending || 0}, active: {program.enrollment_counts.active || 0},
{' '}completed: {program.enrollment_counts.completed || 0}, cancelled: {program.enrollment_counts.cancelled || 0}, expired: {program.enrollment_counts.expired || 0}
</p>
{canAuthor && (
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.updateProgram(programId, { status: program.status === 'active' ? 'inactive' : 'active' }))}>
{program.status === 'active' ? 'Deactivate' : 'Activate'} program
</button>
)}
</div>
{canAuthor && (
<div className={styles.card}>
<h3>Enroll a student</h3>
<EnrollForm programId={programId} onEnrolled={() => { setNotice('Enrolled.'); reload() }} />
</div>
)}
<div className={styles.card}>
<h3>Enrollments</h3>
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Status</th><th>Source</th><th>Enrolled</th><th /></tr></thead>
<tbody>
{enrollments.map((e) => (
<tr key={e.enrollment_id}>
<td>{e.student_display_name}</td>
<td><span className={`${styles.pill} ${e.status === 'active' ? styles.pillOk : e.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{e.status}</span></td>
<td className={styles.muted}>{e.source}</td>
<td className={styles.muted}>{new Date(e.enrolled_at).toLocaleDateString()}</td>
<td>
{e.status === 'pending' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.activateEnrollment(e.enrollment_id))}>Activate</button>}
{['pending', 'active'].includes(e.status) && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.cancelEnrollment(e.enrollment_id))}>Cancel</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
function EnrollForm({ programId, onEnrolled }) {
const [studentId, setStudentId] = useState('')
const [status, setStatus] = useState('pending')
const [startsAt, setStartsAt] = useState('')
const [accessEndsAt, setAccessEndsAt] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createEnrollment({
student_id: studentId.trim(), program_id: programId, status,
starts_at: startsAt || undefined, access_ends_at: accessEndsAt || undefined,
})
setStudentId(''); setStartsAt(''); setAccessEndsAt('')
onEnrolled()
} catch (e) { setError(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}>Student ID</label>
<input className={styles.input} value={studentId} onChange={(e) => setStudentId(e.target.value)} placeholder="student-001" required /></div>
<div><label className={styles.label}>Initial status</label>
<select className={styles.select} value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="pending">pending</option>
<option value="active">active (skip activation step)</option>
</select></div>
<div><label className={styles.label}>Starts (optional)</label>
<input className={styles.input} type="date" value={startsAt} onChange={(e) => setStartsAt(e.target.value)} /></div>
<div><label className={styles.label}>Access ends (optional)</label>
<input className={styles.input} type="date" value={accessEndsAt} onChange={(e) => setAccessEndsAt(e.target.value)} /></div>
</div>
<button className={styles.btn} disabled={busy || !studentId.trim()}>{busy ? 'Enrolling…' : 'Enroll'}</button>
</form>
)
}
+24
View File
@@ -167,6 +167,7 @@ function StudentDetail({ studentId, canManage, onBack }) {
const [env, setEnv] = useState(null)
const [events, setEvents] = useState(null)
const [sessions, setSessions] = useState(null)
const [enrollments, setEnrollments] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
@@ -180,6 +181,9 @@ function StudentDetail({ studentId, canManage, onBack }) {
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
adminApi.studentSessions(studentId).then(setSessions).catch(() => {})
// Phase 5B §11 — scoped server-side the same way as everything else here: a plain
// instructor only sees enrollments they created or that share a cohort with them.
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
}, [studentId])
useEffect(reload, [reload])
@@ -248,6 +252,26 @@ function StudentDetail({ studentId, canManage, onBack }) {
)}
</div>
<div className={styles.card}>
<h3>Enrolled programs</h3>
{!enrollments ? <p className={styles.muted}>Loading…</p> : enrollments.length === 0 ? <p className={styles.muted}>No program enrollments.</p> : (
<table>
<thead><tr><th>Program</th><th>Status</th><th>Delivery</th><th>Progress</th><th>Last activity</th></tr></thead>
<tbody>
{enrollments.map((e) => (
<tr key={e.enrollment_id}>
<td>{e.program_name}</td>
<td><span className={`${styles.pill} ${e.effective_status === 'active' ? styles.pillOk : e.effective_status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{e.effective_status}</span></td>
<td className={styles.muted}>{e.delivery_mode.replace('_', ' ')}</td>
<td className={styles.muted}>{e.progress ? `${e.progress.percent_complete}% — ${e.progress.current_stage || 'capstone'}` : '—'}</td>
<td className={styles.muted}>{e.completed_at ? `Completed ${new Date(e.completed_at).toLocaleDateString()}` : e.activated_at ? new Date(e.activated_at).toLocaleDateString() : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Curriculum progress</h3>
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
+12
View File
@@ -116,4 +116,16 @@ export const adminApi = {
postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }),
acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`),
resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }),
// ── Phase 5B §11: programs and enrollment ───────────────────────────────────────────────
programs: () => get('/api/admin/programs'),
program: (id) => get(`/api/admin/programs/${id}`),
createProgram: (fields) => post('/api/admin/programs', fields),
updateProgram: (id, fields) => patch(`/api/admin/programs/${id}`, fields),
programEnrollments: (id) => get(`/api/admin/programs/${id}/enrollments`),
createEnrollment: (fields) => post('/api/admin/enrollments', fields),
enrollment: (id) => get(`/api/admin/enrollments/${id}`),
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
}