diff --git a/frontend/src/admin/AdminPortal.jsx b/frontend/src/admin/AdminPortal.jsx index ec98dc0..6efd3d5 100644 --- a/frontend/src/admin/AdminPortal.jsx +++ b/frontend/src/admin/AdminPortal.jsx @@ -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' && } {view === 'cohorts' && } {view === 'students' && } + {view === 'learning' && } + {view === 'assessments' && } {view === 'instructors' && instructor.role === 'owner' && } {view === 'audit' && ['owner', 'admin'].includes(instructor.role) && } {view === 'account' && } diff --git a/frontend/src/admin/AssessmentsView.jsx b/frontend/src/admin/AssessmentsView.jsx new file mode 100644 index 0000000..28cc4cf --- /dev/null +++ b/frontend/src/admin/AssessmentsView.jsx @@ -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 { setSelected(null); reload() }} /> + + return ( + <> + {error &&
{error}
} + {canAuthor && ( +
+

Create assessment

+ +
+ )} +
+

Exams & Capstones

+ {!assessments ?

Loading…

: ( + + + + {assessments.map((a) => ( + + + + + + + + + ))} + +
TitleTypeDurationHintsStatus
{a.title}{a.type}{a.duration_minutes} min{a.hint_policy}{a.status}
+ )} +
+ + ) +} + +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 ( +
+ {error &&
{error}
} +
+
+ setForm({ ...form, id: e.target.value })} required />
+
+ setForm({ ...form, title: e.target.value })} required />
+
+
+
+ setForm({ ...form, duration_minutes: e.target.value })} required />
+
+
+
+ +