Files
rootnode-academy/frontend/src/admin/ClassroomView.jsx
T
ced4568 7557fdc753 feat(admin): student credential management, CSV import UI, remote classroom control center
- StudentsView: credential status in the roster table, a create-student form
  (email/login-ID/cohort), convert/reset-password/revoke-sessions controls per student, a
  bulk CSV import form. Every one-time activation/reset link is surfaced exactly once, never
  re-fetchable.
- ClassroomView: cohort picker -> schedule/list classes -> a live dashboard (participants with
  presence/Lab-status/current-work, help queue with acknowledge/resolve, current-activity
  setter, announcement poster, start/end/cancel, Prepare Labs). Polls the dashboard endpoint
  rather than a second push channel.
- AdminPortal: new Classroom nav entry. CohortsView's existing quick-add-student call updated
  for createStudent's new object-argument signature.
2026-08-25 01:19:54 -05:00

217 lines
11 KiB
React

import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
const DASHBOARD_POLL_MS = 5000
// Phase 5A §14/§21 — remote classroom control center. Cohort picker -> that cohort's
// classroom sessions -> a live dashboard once one is open. No video/chat/shell-takeover here
// by design (§37) — this is identity/presence/Lab-status/help-queue/announcements only.
export default function ClassroomView() {
const [cohorts, setCohorts] = useState(null)
const [cohortId, setCohortId] = useState('')
const [classrooms, setClassrooms] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.cohorts().then((rows) => { setCohorts(rows); if (rows.length) setCohortId(rows[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
const reload = useCallback(() => {
if (!cohortId) return
adminApi.classrooms(cohortId).then(setClassrooms).catch((e) => setError(e.message))
}, [cohortId])
useEffect(reload, [reload])
if (selected) return <ClassroomDashboard classroomId={selected} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Classroom</h2>
<div className={styles.formRow}>
<div><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>
</div>
</div>
{cohortId && (
<div className={styles.card}>
<h3>Schedule a class</h3>
<CreateClassroomForm cohortId={cohortId} onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h3>Classes</h3>
{!classrooms ? <p className={styles.muted}>Loading…</p> : classrooms.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Title</th><th>Status</th><th>Scheduled</th><th /></tr></thead>
<tbody>
{classrooms.map((c) => (
<tr key={c.classroom_session_id}>
<td>{c.title}</td>
<td><span className={`${styles.pill} ${c.status === 'live' ? styles.pillOk : c.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{c.status}</span></td>
<td>{c.scheduled_start ? new Date(c.scheduled_start).toLocaleString() : '—'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(c.classroom_session_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateClassroomForm({ cohortId, onCreated }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [scheduledStart, setScheduledStart] = useState('')
const [meetingUrl, setMeetingUrl] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createClassroom(cohortId, {
title: title.trim(), description: description.trim() || undefined,
scheduled_start: scheduledStart || undefined, meeting_url: meetingUrl.trim() || undefined,
})
setTitle(''); setDescription(''); setScheduledStart(''); setMeetingUrl('')
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}>Title</label>
<input className={styles.input} value={title} onChange={(e) => setTitle(e.target.value)} required /></div>
<div><label className={styles.label}>Scheduled start</label>
<input className={styles.input} type="datetime-local" value={scheduledStart} onChange={(e) => setScheduledStart(e.target.value)} /></div>
<div><label className={styles.label}>Meeting URL (Meet/Zoom/Teams — optional)</label>
<input className={styles.input} value={meetingUrl} onChange={(e) => setMeetingUrl(e.target.value)} /></div>
</div>
<label className={styles.label}>Description (optional)</label>
<input className={styles.input} value={description} onChange={(e) => setDescription(e.target.value)} />
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy || !title.trim()}>{busy ? 'Scheduling…' : 'Schedule'}</button>
</form>
)
}
function ClassroomDashboard({ classroomId, onBack }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const [announcement, setAnnouncement] = useState('')
const [activityScenario, setActivityScenario] = useState('')
const reload = useCallback(() => {
adminApi.classroomDashboard(classroomId).then(setData).catch((e) => setError(e.message))
}, [classroomId])
useEffect(() => {
reload()
const t = setInterval(reload, DASHBOARD_POLL_MS) // presence/help-queue/Lab-status all move live — poll rather than a second push channel
return () => clearInterval(t)
}, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (!data) return <>{error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>}</>
const { classroom, students, announcements, help_requests: helpRequests } = data
const openHelp = helpRequests.filter((h) => h.status !== 'resolved')
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to classes</button>
<div className={styles.card} style={{ marginTop: '1rem' }}>
<h2>{classroom.title} <span className={`${styles.pill} ${classroom.status === 'live' ? styles.pillOk : styles.pillMuted}`}>{classroom.status}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.formRow}>
{classroom.status === 'scheduled' && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.startClassroom(classroomId))}>Start class</button>}
{classroom.status === 'live' && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.endClassroom(classroomId))}>End class</button>}
{classroom.status === 'scheduled' && <button className={styles.btnDanger} disabled={busy} onClick={withBusy(() => adminApi.cancelClassroom(classroomId))}>Cancel</button>}
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
const { results } = await adminApi.prepareClassroomLabs(classroomId)
setNotice(`Labs prepared: ${results.length} student(s).`)
})}>Prepare Labs</button>
</div>
</div>
<div className={styles.card}>
<h3>Participants ({students.length})</h3>
{students.length === 0 ? <p className={styles.muted}>No one enrolled in this cohort yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Presence</th><th>Lab</th><th>Current work</th><th>Hints</th></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${s.presence === 'online' ? styles.pillOk : s.presence === 'recently_active' ? styles.pillWarn : styles.pillMuted}`}>{s.presence}</span></td>
<td><span className={`${styles.pill} ${s.lab_status === 'running' ? styles.pillOk : s.lab_status === 'error' ? styles.pillErr : styles.pillMuted}`}>{s.lab_status}</span></td>
<td className={styles.muted}>{s.current_scenario || '—'} {s.attempt_status ? `(${s.attempt_status}, ${s.validation_failures} fails)` : ''}</td>
<td className={styles.muted}>{s.hints_used}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Help queue {openHelp.length > 0 && <span className={`${styles.pill} ${styles.pillWarn}`}>{openHelp.length} open</span>}</h3>
{helpRequests.length === 0 ? <p className={styles.muted}>No help requests.</p> : (
<table>
<thead><tr><th>Student</th><th>Category</th><th>Message</th><th>Status</th><th /></tr></thead>
<tbody>
{helpRequests.map((h) => (
<tr key={h.help_request_id}>
<td>{h.display_name || h.student_id}</td>
<td className={styles.muted}>{h.category}</td>
<td className={styles.muted}>{h.message || '—'}</td>
<td><span className={`${styles.pill} ${h.status === 'resolved' ? styles.pillOk : h.status === 'acknowledged' ? styles.pillWarn : styles.pillErr}`}>{h.status}</span></td>
<td>
{h.status === 'open' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.acknowledgeHelpRequest(h.help_request_id))}>Acknowledge</button>}
{h.status !== 'resolved' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.resolveHelpRequest(h.help_request_id))}>Resolve</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Current activity</h3>
<p className={styles.muted}>Guidance only — students see this as a link, nothing auto-redirects them (§20).</p>
<div className={styles.formRow}>
<div><input className={styles.input} placeholder="scenario id" value={activityScenario} onChange={(e) => setActivityScenario(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || !activityScenario.trim()} onClick={withBusy(async () => { await adminApi.setClassroomActivity(classroomId, activityScenario.trim()); setActivityScenario('') })}>Set</button></div>
</div>
</div>
<div className={styles.card}>
<h3>Announcements</h3>
{announcements.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<ul>{announcements.map((a) => <li key={a.announcement_id} className={styles.muted}>{a.message}</li>)}</ul>
)}
<div className={styles.formRow} style={{ marginTop: '0.5rem' }}>
<div><input className={styles.input} placeholder="Post an announcement" value={announcement} onChange={(e) => setAnnouncement(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || !announcement.trim()} onClick={withBusy(async () => { await adminApi.postClassroomAnnouncement(classroomId, announcement.trim()); setAnnouncement('') })}>Post</button></div>
</div>
</div>
</>
)
}