diff --git a/frontend/src/admin/Admin.module.css b/frontend/src/admin/Admin.module.css
index d20e261..47e79f9 100644
--- a/frontend/src/admin/Admin.module.css
+++ b/frontend/src/admin/Admin.module.css
@@ -57,3 +57,6 @@ tr:hover td { background: var(--surface2); }
.linkBtn { background: none; border: none; color: var(--blue); cursor: pointer; font-size: 0.85rem; padding: 0; text-decoration: underline; }
.errorBox { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); padding: 0.6rem 0.8rem; font-size: 0.85rem; margin-bottom: 1rem; }
.muted { color: var(--text-2); font-size: 0.85rem; }
+
+/* Phase 4.1 §16 — shared pagination control (AuditView, StudentsView attempts). */
+.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
diff --git a/frontend/src/admin/AssessmentsView.jsx b/frontend/src/admin/AssessmentsView.jsx
index 28cc4cf..08da4af 100644
--- a/frontend/src/admin/AssessmentsView.jsx
+++ b/frontend/src/admin/AssessmentsView.jsx
@@ -29,7 +29,7 @@ export default function AssessmentsView({ role }) {
{canAuthor && (
@@ -56,20 +56,86 @@ export default function AssessmentsView({ role }) {
)
}
-function CreateAssessmentForm({ onCreated }) {
- const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
+// Phase 4.1 §18 — pool_spec is built from structured picks (required scenarios + named random
+// pools), not hand-typed JSON. `catalog` ({ stages: [{ id, name, scenarios: [{id,title}] }] })
+// comes from GET /api/admin/scenario-catalog. An owner can still drop into a raw-JSON view for
+// anything the structured editor doesn't cover — see the toggle below.
+function PoolSpecEditor({ catalog, required, setRequired, pools, setPools }) {
+ const allScenarios = (catalog?.stages || []).flatMap((s) => s.scenarios.map((sc) => ({ ...sc, stageName: s.name })))
+ const titleFor = (id) => allScenarios.find((s) => s.id === id)?.title || id
+
+ const addPool = () => setPools([...pools, { count: 1, from: [] }])
+ const removePool = (i) => setPools(pools.filter((_, idx) => idx !== i))
+ const updatePool = (i, patch) => setPools(pools.map((p, idx) => (idx === i ? { ...p, ...patch } : p)))
+
+ return (
+
+
+
+
{required.length ? `${required.length} selected: ${required.map(titleFor).join(', ')}` : 'None selected — cmd/ctrl-click to pick multiple.'}
+
+
+ {pools.map((pool, i) => (
+
+
+
+ updatePool(i, { count: Number(e.target.value) })} />
+
+
+
+
+ {pool.count > pool.from.length &&
Draw count ({pool.count}) exceeds the pool size ({pool.from.length}) — every draw will use every scenario, not a random subset.
}
+
+ ))}
+
+
+ )
+}
+
+function CreateAssessmentForm({ onCreated, role }) {
+ const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
+ const [catalog, setCatalog] = useState(null)
+ const [required, setRequired] = useState([])
+ const [pools, setPools] = useState([])
+ const [advanced, setAdvanced] = useState(false)
+ const [rawPoolSpec, setRawPoolSpec] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
+ useEffect(() => { adminApi.scenarioCatalog().then(setCatalog).catch((e) => setError(e.message)) }, [])
+
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
- const pool_spec = JSON.parse(form.pool_spec || '{"required":[],"random":[]}')
+ // Owner's raw-JSON view, when open, is the source of truth — otherwise built from the
+ // structured pickers above.
+ const pool_spec = advanced
+ ? JSON.parse(rawPoolSpec || '{"required":[],"random":[]}')
+ : { required, random: pools.filter((p) => p.count > 0 && p.from.length > 0) };
+ if (!advanced && !required.length && !pool_spec.random.length) {
+ throw new Error('Pick at least one required scenario or one random pool.')
+ }
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: '' })
+ setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
+ setRequired([]); setPools([]); setRawPoolSpec('')
onCreated()
- } catch (e) { setError(e.message?.includes('JSON') ? 'pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
+ } catch (e) { setError(e.message?.includes('JSON') ? 'Raw pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
}
return (
@@ -91,26 +157,47 @@ function CreateAssessmentForm({ onCreated }) {
-