166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
import { type FormEvent, useEffect, useState } from 'react'
|
|
import { Link, useSearchParams } from 'react-router-dom'
|
|
import { ApiHttpError } from '../../api/http'
|
|
import { fetchPlacementSection, postPlacementSection } from '../../api/grading'
|
|
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
|
import { rowsFromUnknown } from './gradingPayloadHelpers'
|
|
import { GRADING_PLACEMENT_INDEX_PATH } from './gradingPaths'
|
|
|
|
/** CI `grading/placement.php` — per-section placement scores. */
|
|
export function PlacementSectionPage() {
|
|
const [searchParams] = useSearchParams()
|
|
const classSectionId = searchParams.get('class_section_id') ?? ''
|
|
|
|
const [rows, setRows] = useState<Record<string, unknown>[]>([])
|
|
const [locked, setLocked] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!classSectionId) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
let c = false
|
|
setLoading(true)
|
|
fetchPlacementSection(searchParams)
|
|
.then((p) => {
|
|
if (c) return
|
|
setRows(rowsFromUnknown(p))
|
|
if (p && typeof p === 'object') {
|
|
const o = p as Record<string, unknown>
|
|
setLocked(Boolean(o.scoresLocked ?? o.locked))
|
|
}
|
|
setError(null)
|
|
})
|
|
.catch((e: unknown) => {
|
|
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load placement.')
|
|
})
|
|
.finally(() => {
|
|
if (!c) setLoading(false)
|
|
})
|
|
return () => {
|
|
c = true
|
|
}
|
|
}, [searchParams, classSectionId])
|
|
|
|
const scoreKey = (row: Record<string, unknown>): string => {
|
|
if ('placement_score' in row) return 'placement_score'
|
|
if ('score' in row) return 'score'
|
|
return 'placement_score'
|
|
}
|
|
|
|
function setScore(i: number, value: string) {
|
|
setRows((prev) => {
|
|
const next = [...prev]
|
|
const k = scoreKey(next[i] ?? {})
|
|
next[i] = { ...next[i], [k]: value === '' ? null : Number(value) }
|
|
return next
|
|
})
|
|
}
|
|
|
|
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
await postPlacementSection({
|
|
class_section_id: Number(classSectionId),
|
|
rows,
|
|
school_year: searchParams.get('school_year') ?? undefined,
|
|
semester: searchParams.get('semester') ?? undefined,
|
|
})
|
|
const next = await fetchPlacementSection(searchParams)
|
|
setRows(rowsFromUnknown(next))
|
|
} catch (err: unknown) {
|
|
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const qs = searchParams.toString()
|
|
const cols = rows[0] ? Object.keys(rows[0]) : ['student_id', 'firstname', 'lastname', 'placement_score']
|
|
|
|
if (!classSectionId) {
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="alert alert-info">
|
|
Add <code>class_section_id</code> to the URL (open from Placement index).
|
|
</div>
|
|
<Link className="btn btn-secondary" to={GRADING_PLACEMENT_INDEX_PATH}>
|
|
Placement index
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="container-fluid py-3">
|
|
<h2 className="text-center mt-2 mb-3">Placement scores</h2>
|
|
<p className="text-center text-muted small">Section #{classSectionId}</p>
|
|
<AcademicFilterBar />
|
|
|
|
<div className="mb-3">
|
|
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PLACEMENT_INDEX_PATH}${qs ? `?${qs}` : ''}`}>
|
|
All sections
|
|
</Link>
|
|
</div>
|
|
|
|
{locked ? <div className="alert alert-warning">Scores are locked.</div> : null}
|
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
{loading ? <p className="text-muted">Loading…</p> : null}
|
|
|
|
{!loading && rows.length > 0 ? (
|
|
<form onSubmit={(ev) => void onSubmit(ev)}>
|
|
<div className="table-responsive">
|
|
<table className="table table-bordered align-middle">
|
|
<thead className="table-light">
|
|
<tr>
|
|
{cols.map((c) => (
|
|
<th key={c}>{c.replace(/_/g, ' ')}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, i) => {
|
|
const sk = scoreKey(row)
|
|
return (
|
|
<tr key={i}>
|
|
{cols.map((col) =>
|
|
col === sk && !locked ? (
|
|
<td key={col}>
|
|
<input
|
|
type="number"
|
|
className="form-control form-control-sm"
|
|
style={{ width: 96 }}
|
|
value={row[col] === null || row[col] === undefined ? '' : String(row[col])}
|
|
onChange={(ev) => setScore(i, ev.target.value)}
|
|
/>
|
|
</td>
|
|
) : (
|
|
<td key={col}>{row[col] === null || row[col] === undefined ? '—' : String(row[col])}</td>
|
|
),
|
|
)}
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="text-center mt-3">
|
|
<button type="submit" className="btn btn-primary" disabled={locked || saving}>
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : null}
|
|
|
|
{!loading && rows.length === 0 ? (
|
|
<div className="alert alert-light border">No rows for this section.</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|