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 && (

Create assessment

- +
)}
@@ -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 }) {
- -