add grading, attendnace management

This commit is contained in:
root
2026-04-29 17:39:37 -04:00
parent ea495f8382
commit dc7acf2536
27 changed files with 4150 additions and 960 deletions
+128 -50
View File
@@ -1,20 +1,32 @@
import { type FormEvent, useEffect, useState } from 'react'
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 { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { rowsFromUnknown } from './gradingPayloadHelpers'
import { GRADING_PATH } from './gradingPaths'
/** CI `grading/participation.php` */
export function ParticipationPage() {
const [searchParams] = useSearchParams()
const [rows, setRows] = useState<Record<string, unknown>[]>([])
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, setTitle] = useState('Participation scores')
const [title, setTitle] = useState('Participation Scores')
const [sectionName, setSectionName] = useState('')
const [semester, setSemester] = useState(searchParams.get('semester') ?? '')
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
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') ?? ''
useEffect(() => {
let c = false
@@ -22,11 +34,26 @@ export function ParticipationPage() {
fetchParticipation(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))
if (typeof o.title === 'string') setTitle(o.title)
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: scores.score ?? null,
}
}),
)
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 : (searchParams.get('school_year') ?? ''))
}
setError(null)
})
@@ -41,17 +68,18 @@ export function ParticipationPage() {
}
}, [searchParams])
const scoreKey = (row: Record<string, unknown>): string => {
if ('participation_score' in row) return 'participation_score'
if ('score' in row) return 'score'
return 'participation_score'
}
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]
const k = scoreKey(next[i] ?? {})
next[i] = { ...next[i], [k]: value === '' ? null : Number(value) }
next[i] = { ...next[i], score: value === '' ? null : Number(value) }
return next
})
}
@@ -62,13 +90,33 @@ export function ParticipationPage() {
setError(null)
try {
const body: Record<string, unknown> = {
rows,
school_year: searchParams.get('school_year') ?? undefined,
semester: searchParams.get('semester') ?? undefined,
class_section_id: classSectionId ? Number(classSectionId) : undefined,
school_year: schoolYear || 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(searchParams)
setRows(rowsFromUnknown(next))
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: scores.score ?? null,
}
}),
)
}
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
@@ -77,12 +125,13 @@ export function ParticipationPage() {
}
const qs = searchParams.toString()
const cols = rows[0] ? Object.keys(rows[0]) : ['student_id', 'firstname', 'lastname', 'participation_score']
return (
<div className="container-fluid py-3">
<h2 className="text-center mt-2 mb-3">{title}</h2>
<AcademicFilterBar />
<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}` : ''}`}>
@@ -98,44 +147,73 @@ export function ParticipationPage() {
{!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 align-middle">
<table className="table table-bordered table-striped align-middle">
<thead className="table-light">
<tr>
{cols.map((c) => (
<th key={c}>{c.replace(/_/g, ' ')}</th>
))}
<th>#</th>
<th>School ID</th>
<th>First Name</th>
<th>Last Name</th>
<th className="text-center">Score</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>
)
})}
{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'}
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>