- 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.
217 lines
11 KiB
React
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}>← 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>
|
|
</>
|
|
)
|
|
}
|