247 lines
9.6 KiB
TypeScript
247 lines
9.6 KiB
TypeScript
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
|
import { Link, useSearchParams } from 'react-router-dom'
|
|
import { ApiHttpError } from '../../api/http'
|
|
import { fetchParticipation, postParticipation } from '../../api/grading'
|
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
|
import { GRADING_PATH } from './gradingPaths'
|
|
|
|
/** CI `grading/participation.php` */
|
|
function normalizeParticipationScore(value: unknown): number | string | null {
|
|
return typeof value === 'number' || typeof value === 'string' ? value : null
|
|
}
|
|
|
|
export function ParticipationPage() {
|
|
const [searchParams] = useSearchParams()
|
|
const requestedSchoolYear = searchParams.get('school_year') ?? ''
|
|
const { selectedYear } = useSchoolYearOptions({
|
|
preferredYear: requestedSchoolYear,
|
|
})
|
|
const [rows, setRows] = useState<
|
|
Array<{
|
|
student_id: number
|
|
school_id?: string | null
|
|
firstname?: string | null
|
|
lastname?: string | null
|
|
score?: number | string | null
|
|
}>
|
|
>([])
|
|
const [locked, setLocked] = useState(false)
|
|
const [title] = useState('Participation Scores')
|
|
const [sectionName, setSectionName] = useState('')
|
|
const [semester, setSemester] = useState(searchParams.get('semester') ?? '')
|
|
const [schoolYear, setSchoolYear] = useState(requestedSchoolYear || selectedYear)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [tableSearch, setTableSearch] = useState('')
|
|
|
|
const classSectionId = searchParams.get('class_section_id') ?? ''
|
|
const effectiveSchoolYear = schoolYear || selectedYear
|
|
const participationSearchParams = useMemo(() => {
|
|
const next = new URLSearchParams(searchParams)
|
|
if (effectiveSchoolYear) next.set('school_year', effectiveSchoolYear)
|
|
return next
|
|
}, [effectiveSchoolYear, searchParams])
|
|
|
|
useEffect(() => {
|
|
let c = false
|
|
setLoading(true)
|
|
fetchParticipation(participationSearchParams)
|
|
.then((p) => {
|
|
if (c) return
|
|
if (p && typeof p === 'object') {
|
|
const o = p as Record<string, unknown>
|
|
const students = Array.isArray(o.students) ? o.students : []
|
|
setRows(
|
|
students.map((row) => {
|
|
const student = row as Record<string, unknown>
|
|
const scores = (student.scores ?? {}) as Record<string, unknown>
|
|
return {
|
|
student_id: Number(student.student_id ?? 0),
|
|
school_id: typeof student.school_id === 'string' ? student.school_id : null,
|
|
firstname: typeof student.firstname === 'string' ? student.firstname : null,
|
|
lastname: typeof student.lastname === 'string' ? student.lastname : null,
|
|
score: normalizeParticipationScore(scores.score),
|
|
}
|
|
}),
|
|
)
|
|
setLocked(Boolean(o.scores_locked ?? o.scoresLocked ?? o.locked))
|
|
setSectionName(typeof o.class_section_name === 'string' ? o.class_section_name : '')
|
|
setSemester(typeof o.semester === 'string' ? o.semester : (searchParams.get('semester') ?? ''))
|
|
setSchoolYear(
|
|
typeof o.school_year === 'string' && o.school_year.trim() !== ''
|
|
? o.school_year
|
|
: effectiveSchoolYear,
|
|
)
|
|
}
|
|
setError(null)
|
|
})
|
|
.catch((e: unknown) => {
|
|
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load participation.')
|
|
})
|
|
.finally(() => {
|
|
if (!c) setLoading(false)
|
|
})
|
|
return () => {
|
|
c = true
|
|
}
|
|
}, [effectiveSchoolYear, participationSearchParams, searchParams])
|
|
|
|
const visibleRows = useMemo(() => {
|
|
const normalized = tableSearch.toLowerCase().trim()
|
|
if (normalized === '') return rows
|
|
return rows.filter((row) =>
|
|
`${row.school_id ?? ''} ${row.firstname ?? ''} ${row.lastname ?? ''}`.toLowerCase().includes(normalized),
|
|
)
|
|
}, [rows, tableSearch])
|
|
|
|
function setScore(i: number, value: string) {
|
|
setRows((prev) => {
|
|
const next = [...prev]
|
|
next[i] = { ...next[i], score: value === '' ? null : Number(value) }
|
|
return next
|
|
})
|
|
}
|
|
|
|
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
const body: Record<string, unknown> = {
|
|
class_section_id: classSectionId ? Number(classSectionId) : undefined,
|
|
school_year: effectiveSchoolYear || undefined,
|
|
semester: semester || undefined,
|
|
scores: rows.reduce<Record<string, { score: number | string | null }>>((carry, row) => {
|
|
carry[String(row.student_id)] = { score: row.score ?? '' }
|
|
return carry
|
|
}, {}),
|
|
}
|
|
await postParticipation(body)
|
|
const next = await fetchParticipation(participationSearchParams)
|
|
if (next && typeof next === 'object') {
|
|
const o = next as Record<string, unknown>
|
|
const students = Array.isArray(o.students) ? o.students : []
|
|
setRows(
|
|
students.map((row) => {
|
|
const student = row as Record<string, unknown>
|
|
const scores = (student.scores ?? {}) as Record<string, unknown>
|
|
return {
|
|
student_id: Number(student.student_id ?? 0),
|
|
school_id: typeof student.school_id === 'string' ? student.school_id : null,
|
|
firstname: typeof student.firstname === 'string' ? student.firstname : null,
|
|
lastname: typeof student.lastname === 'string' ? student.lastname : null,
|
|
score: normalizeParticipationScore(scores.score),
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
} catch (err: unknown) {
|
|
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const qs = searchParams.toString()
|
|
|
|
return (
|
|
<div className="container mt-5">
|
|
<h2 className="text-center mb-4">{title}</h2>
|
|
<p className="text-center text-muted small">
|
|
{(sectionName || (classSectionId ? `Section ${classSectionId}` : 'Section'))} • {semester || '—'} • {schoolYear || '—'}
|
|
</p>
|
|
|
|
<div className="mb-3">
|
|
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
|
|
Grading hub
|
|
</Link>
|
|
</div>
|
|
|
|
{locked ? (
|
|
<div className="alert alert-warning">Scores are locked for this selection.</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="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
|
<div className="d-flex align-items-center gap-2">
|
|
<span>Show</span>
|
|
<select className="form-select form-select-sm" style={{ width: 78 }} defaultValue="100" disabled>
|
|
<option value="100">100</option>
|
|
</select>
|
|
<span>entries</span>
|
|
</div>
|
|
<div className="d-flex align-items-center gap-2">
|
|
<label className="mb-0">Search:</label>
|
|
<input
|
|
className="form-control form-control-sm"
|
|
style={{ width: 220 }}
|
|
value={tableSearch}
|
|
onChange={(event) => setTableSearch(event.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="table-responsive">
|
|
<table className="table table-bordered table-striped align-middle">
|
|
<thead className="table-light">
|
|
<tr>
|
|
<th>#</th>
|
|
<th>School ID</th>
|
|
<th>First Name</th>
|
|
<th>Last Name</th>
|
|
<th className="text-center">Score</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleRows.map((row, i) => (
|
|
<tr key={row.student_id}>
|
|
<td>{i + 1}</td>
|
|
<td>{row.school_id ?? '—'}</td>
|
|
<td>{row.firstname ?? '—'}</td>
|
|
<td>{row.lastname ?? '—'}</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="0.01"
|
|
className="form-control text-center"
|
|
disabled={locked}
|
|
value={row.score ?? ''}
|
|
onChange={(ev) => {
|
|
const originalIndex = rows.findIndex((candidate) => candidate.student_id === row.student_id)
|
|
if (originalIndex >= 0) setScore(originalIndex, ev.target.value)
|
|
}}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{visibleRows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="text-muted text-center">
|
|
No students match the current search.
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="text-center mt-3">
|
|
<button type="submit" className="btn btn-primary" disabled={locked || saving}>
|
|
{saving ? 'Saving…' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : null}
|
|
|
|
{!loading && rows.length === 0 ? (
|
|
<div className="alert alert-light border">No students for this filter.</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|