fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
@@ -0,0 +1,409 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useAuth } from '../../auth/AuthProvider'
import {
fetchCompetitionScoresIndex,
fetchParentEnrollments,
fetchScoreCardSelectableStudents,
fetchStudentScoreCard,
} from '../../api/session'
import type { ScoreCardSelectableStudent } from '../../api/session'
import type { StudentScoreCardRow } from '../../api/types'
import {
parseScoreSortLabel,
rowsForYear,
scoreForSemester,
semesterHeaderLabel,
sortSemesterKeys,
} from './scoreCardListUtils'
const LS_CLASS = 'alrahma_teacher_score_card_class_section_id'
const LS_YEAR = 'alrahma_teacher_score_card_school_year'
function backHomePath(selectedRole: string | null): string {
const r = (selectedRole ?? '').toLowerCase()
if (r.includes('parent')) return '/app/parent/home'
if (r.includes('teacher')) return '/app/teacher_dashboard'
return '/app/home'
}
function isTeacherLikeRole(role: string | null): boolean {
const r = (role ?? '').toLowerCase()
return r.includes('teacher')
}
/**
* CI `StudentController::scoreCardList` uses session `class_section_id` + `school_year`, or
* `teacher_class` rows. We mirror that by passing query params to the API and by resolving the
* active class from competition-scores index (same “active class” as other teacher flows).
*/
async function resolveTeacherScoreCardContext(searchParams: URLSearchParams): Promise<{
classSectionId: number
schoolYear: string | null
}> {
const fromUrlC = Number(searchParams.get('class_section_id') ?? '0')
const fromUrlY = searchParams.get('school_year')?.trim() || null
if (fromUrlC > 0) {
return { classSectionId: fromUrlC, schoolYear: fromUrlY }
}
try {
const lsC = Number(localStorage.getItem(LS_CLASS) ?? '0')
const lsY = localStorage.getItem(LS_YEAR)?.trim() || null
if (lsC > 0) {
return { classSectionId: lsC, schoolYear: fromUrlY ?? lsY }
}
} catch {
/* ignore */
}
try {
const comp = await fetchCompetitionScoresIndex(null)
const cid = Number(comp.activeClassId ?? 0)
const sy =
comp.schoolYear != null && String(comp.schoolYear).trim() !== '' ? String(comp.schoolYear).trim() : null
if (cid > 0) {
try {
localStorage.setItem(LS_CLASS, String(cid))
if (sy) localStorage.setItem(LS_YEAR, sy)
} catch {
/* ignore */
}
return { classSectionId: cid, schoolYear: sy }
}
} catch {
/* ignore */
}
return { classSectionId: 0, schoolYear: fromUrlY }
}
function filterStudentsToClassSection(
rows: ScoreCardSelectableStudent[],
classSectionId: number,
): ScoreCardSelectableStudent[] {
if (classSectionId <= 0) return rows
const hasSectionHints = rows.some((s) => Array.isArray(s.class_section_ids) && s.class_section_ids.length > 0)
if (!hasSectionHints) return rows
return rows.filter((s) => (s.class_section_ids ?? []).includes(classSectionId))
}
async function resolveDefaultSchoolYear(): Promise<string | null> {
try {
const comp = await fetchCompetitionScoresIndex(null)
const sy = comp.schoolYear != null && String(comp.schoolYear).trim() !== '' ? String(comp.schoolYear).trim() : null
return sy
} catch {
return null
}
}
export function StudentScoreCardListPage() {
const { selectedRole } = useAuth()
const [searchParams] = useSearchParams()
const [students, setStudents] = useState<ScoreCardSelectableStudent[]>([])
const [listSchoolYear, setListSchoolYear] = useState<string | null>(null)
const [semesterKeys, setSemesterKeys] = useState<string[]>([])
const [scoreTableYear, setScoreTableYear] = useState<string>('')
const [scoreByStudent, setScoreByStudent] = useState<Record<number, Record<string, string>>>({})
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [loadingScores, setLoadingScores] = useState(false)
/** `school_id` | `student` | semester key (e.g. `fall`). */
const [sortKey, setSortKey] = useState<string>('student')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const backUrl = useMemo(() => backHomePath(selectedRole), [selectedRole])
const sortedStudents = useMemo(() => {
const list = [...students]
const m = sortDir === 'asc' ? 1 : -1
list.sort((a, b) => {
if (sortKey === 'school_id') {
return m * String(a.school_id ?? '').localeCompare(String(b.school_id ?? ''), undefined, { numeric: true })
}
if (sortKey === 'student') {
const la = `${a.lastname ?? ''} ${a.firstname ?? ''}`.trim().toLowerCase()
const lb = `${b.lastname ?? ''} ${b.firstname ?? ''}`.trim().toLowerCase()
let c = la.localeCompare(lb)
if (c === 0) c = String(a.firstname ?? '').localeCompare(String(b.firstname ?? ''))
return m * c
}
const sa = scoreByStudent[a.id]?.[sortKey] ?? '—'
const sb = scoreByStudent[b.id]?.[sortKey] ?? '—'
const na = parseScoreSortLabel(sa)
const nb = parseScoreSortLabel(sb)
const aNaN = Number.isNaN(na)
const bNaN = Number.isNaN(nb)
if (aNaN && bNaN) {
/* tie-break by name */
} else if (aNaN) return m * 1
else if (bNaN) return m * -1
else if (na !== nb) return m * (na < nb ? -1 : 1)
const la = `${a.lastname ?? ''} ${a.firstname ?? ''}`.trim().toLowerCase()
const lb = `${b.lastname ?? ''} ${b.firstname ?? ''}`.trim().toLowerCase()
return m * la.localeCompare(lb)
})
return list
}, [students, sortKey, sortDir, scoreByStudent])
function toggleSort(key: string): void {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir('asc')
}
}
function sortIndicator(key: string): string {
if (sortKey !== key) return ''
return sortDir === 'asc' ? ' ▲' : ' ▼'
}
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
const role = (selectedRole ?? '').toLowerCase()
try {
if (isTeacherLikeRole(selectedRole)) {
const { classSectionId, schoolYear } = await resolveTeacherScoreCardContext(searchParams)
if (cancelled) return
if (classSectionId <= 0) {
setStudents([])
setListSchoolYear(null)
setError(
'No class section is selected for your teacher account. Open Competition Scores or another class tool first, or add ?class_section_id=…&school_year=… to this pages URL (same as the legacy portal session).',
)
return
}
const data = await fetchScoreCardSelectableStudents({
classSectionId,
schoolYear,
})
if (cancelled) return
const raw = data.students ?? []
setStudents(filterStudentsToClassSection(raw, classSectionId))
setListSchoolYear(schoolYear ?? (await resolveDefaultSchoolYear()))
setError(null)
return
}
const defaultYear = await resolveDefaultSchoolYear()
if (cancelled) return
const data = await fetchScoreCardSelectableStudents()
if (cancelled) return
setStudents(data.students ?? [])
setListSchoolYear(defaultYear)
setError(null)
} catch {
if (cancelled) return
if (isTeacherLikeRole(selectedRole)) {
setStudents([])
setListSchoolYear(null)
setSemesterKeys([])
setScoreByStudent({})
setError(
'Unable to load students for your class. The API should accept ?class_section_id=…&school_year=… on GET /api/v1/students/score-card/selectable (same rules as legacy scoreCardList), or return class_section_ids on each student for filtering.',
)
return
}
if (role.includes('parent')) {
try {
const en = await fetchParentEnrollments(null)
if (cancelled) return
setStudents(
(en.students ?? []).map((s) => ({
id: s.id,
school_id: (s as { school_id?: string }).school_id ?? null,
firstname: s.firstname,
lastname: s.lastname,
})),
)
setListSchoolYear(await resolveDefaultSchoolYear())
setError(null)
} catch (e) {
if (!cancelled) {
setStudents([])
setListSchoolYear(null)
setError(e instanceof Error ? e.message : 'Unable to load students.')
}
}
} else {
setStudents([])
setListSchoolYear(null)
setError(
'Unable to load the student list. Ask your administrator to enable GET /api/v1/students/score-card/selectable for your role.',
)
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [selectedRole, searchParams.toString()])
useEffect(() => {
if (students.length === 0) {
setSemesterKeys([])
setScoreByStudent({})
setScoreTableYear('')
setLoadingScores(false)
return
}
let cancelled = false
;(async () => {
setLoadingScores(true)
try {
const packs: { id: number; rows: StudentScoreCardRow[] }[] = await Promise.all(
students.map(async (s) => {
try {
const d = await fetchStudentScoreCard(s.id)
return { id: s.id, rows: d.rows ?? [] }
} catch {
return { id: s.id, rows: [] }
}
}),
)
if (cancelled) return
let effectiveYear = listSchoolYear?.trim() ?? ''
if (!effectiveYear) {
const years = new Set<string>()
for (const p of packs) {
for (const r of p.rows) {
const y = String(r.school_year ?? '').trim()
if (y) years.add(y)
}
}
effectiveYear = Array.from(years).sort().pop() ?? ''
}
const semSet = new Set<string>()
for (const p of packs) {
for (const r of rowsForYear(p.rows, effectiveYear || null)) {
const sem = String(r.semester ?? '').trim().toLowerCase()
if (sem) semSet.add(sem)
}
}
let semesters = Array.from(semSet).sort(sortSemesterKeys)
if (semesters.length === 0) {
semesters = ['fall', 'spring']
}
const matrix: Record<number, Record<string, string>> = {}
for (const p of packs) {
matrix[p.id] = {}
for (const sem of semesters) {
matrix[p.id][sem] = scoreForSemester(p.rows, effectiveYear || null, sem)
}
}
setSemesterKeys(semesters)
setScoreByStudent(matrix)
setScoreTableYear(effectiveYear)
} finally {
if (!cancelled) setLoadingScores(false)
}
})()
return () => {
cancelled = true
}
}, [students, listSchoolYear])
const yearLine = scoreTableYear ? `School year: ${scoreTableYear}` : 'School year: (all loaded years)'
return (
<div className="container-xxl py-4">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 className="mb-0">Student Score Card</h2>
<div className="text-muted small">{yearLine} semester columns show semester score; open the full card from a cell.</div>
</div>
<Link className="btn btn-outline-secondary" to={backUrl}>
Back
</Link>
</div>
{loading ? <div className="text-muted small">Loading</div> : null}
{error ? <div className="alert alert-warning">{error}</div> : null}
{!loading && students.length === 0 && !error ? (
<div className="alert alert-warning">No students available for your account.</div>
) : null}
{!loading && students.length > 0 ? (
<div className="card shadow-sm">
<div className="card-body table-responsive pt-2">
{loadingScores ? <div className="text-muted small mb-2">Loading semester scores</div> : null}
<table className="table table-striped align-middle mb-0">
<thead className="table-light">
<tr>
<th scope="col" className="text-nowrap">
<button
type="button"
className="btn btn-link btn-sm text-dark fw-semibold p-0 text-decoration-none"
onClick={() => toggleSort('school_id')}
>
School ID
<span className="text-muted small">{sortIndicator('school_id')}</span>
</button>
</th>
<th scope="col" className="text-nowrap">
<button
type="button"
className="btn btn-link btn-sm text-dark fw-semibold p-0 text-decoration-none"
onClick={() => toggleSort('student')}
>
Student
<span className="text-muted small">{sortIndicator('student')}</span>
</button>
</th>
{semesterKeys.map((sem) => (
<th key={sem} className="text-center text-nowrap">
<button
type="button"
className="btn btn-link btn-sm text-dark fw-semibold p-0 text-decoration-none"
onClick={() => toggleSort(sem)}
>
{semesterHeaderLabel(sem)}
<span className="text-muted small">{sortIndicator(sem)}</span>
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sortedStudents.map((stu) => {
const label = `${stu.firstname ?? ''} ${stu.lastname ?? ''}`.trim()
return (
<tr key={stu.id}>
<td>{stu.school_id ?? ''}</td>
<td>{label}</td>
{semesterKeys.map((sem) => {
const text = scoreByStudent[stu.id]?.[sem] ?? '—'
const sp = new URLSearchParams()
if (scoreTableYear) sp.set('school_year', scoreTableYear)
sp.set('semester', semesterHeaderLabel(sem))
const to = `/app/student/score-card/${stu.id}?${sp.toString()}`
return (
<td key={sem} className="text-center text-nowrap">
<Link className="btn btn-sm btn-outline-primary" to={to}>
{text}
</Link>
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
) : null}
</div>
)
}
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { fetchStudentScoreCard } from '../../api/session'
import type { StudentScoreCardRow, StudentScoreCardStudent } from '../../api/types'
import { reportCardStudentUrl } from '../../api/printables'
function formatScore(val: unknown): string {
if (val === null || val === undefined || val === '') return 'N/A'
if (typeof val === 'number' && Number.isFinite(val)) {
let out = val.toFixed(1)
if (out.endsWith('.0')) out = out.slice(0, -2)
return out
}
if (typeof val === 'string' && val.trim() !== '' && !Number.isNaN(Number(val))) {
const n = Number(val)
let out = n.toFixed(1)
if (out.endsWith('.0')) out = out.slice(0, -2)
return out
}
return String(val)
}
function labelFromType(type: string): string {
const map: Record<string, string> = {
midterm: 'Midterm',
final: 'Final',
ptap: 'PTAP',
attendance: 'Attendance',
general: 'General',
}
const key = type.trim().toLowerCase()
return map[key] ?? (key ? key.charAt(0).toUpperCase() + key.slice(1) : type)
}
export function StudentScoreCardPage() {
const { studentId } = useParams()
const [searchParams] = useSearchParams()
const id = Number(studentId)
const [student, setStudent] = useState<StudentScoreCardStudent | null>(null)
const [rows, setRows] = useState<StudentScoreCardRow[]>([])
const [error, setError] = useState<string | null>(null)
const highlightYear = searchParams.get('school_year')?.trim() ?? ''
const highlightSem = searchParams.get('semester')?.trim().toLowerCase() ?? ''
useEffect(() => {
if (!id) return
let cancelled = false
;(async () => {
try {
const data = await fetchStudentScoreCard(id)
if (cancelled) return
setStudent(data.student)
setRows(data.rows ?? [])
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load score card.')
}
})()
return () => {
cancelled = true
}
}, [id])
useLayoutEffect(() => {
if (!highlightYear || !highlightSem || rows.length === 0) return
const idx = rows.findIndex((r) => {
const y = String(r.school_year ?? '').trim()
const s = String(r.semester ?? '').trim().toLowerCase()
return y === highlightYear && s === highlightSem
})
if (idx < 0) return
window.requestAnimationFrame(() => {
document.getElementById(`score-card-row-${idx}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
}, [highlightYear, highlightSem, rows])
const yearCounts = useMemo(() => {
const m: Record<string, number> = {}
for (const r of rows) {
const y = String(r.school_year ?? '')
m[y] = (m[y] ?? 0) + 1
}
return m
}, [rows])
const firstRowIndexByYear = useMemo(() => {
const map = new Map<string, number>()
rows.forEach((row, idx) => {
const y = String(row.school_year ?? '')
if (!map.has(y)) map.set(y, idx)
})
return map
}, [rows])
const yearStripe = useMemo(() => {
const keyToAlt: Record<string, number> = {}
let order = 0
return rows.map((row) => {
const y = String(row.school_year ?? '')
if (y === '') return 0
if (keyToAlt[y] === undefined) {
keyToAlt[y] = order % 2
order += 1
}
return keyToAlt[y]
})
}, [rows])
const heading = student
? `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()
: ''
return (
<div className="w-100 py-3 px-3">
<style>{`
.score-card-table { margin-top: 4px; }
.score-card-table thead th { position: static; }
.score-card-table tbody tr.score-year-alt-0 > * { background-color: #fff1e6 !important; }
.score-card-table tbody tr.score-year-alt-1 > * { background-color: #e8f1ff !important; }
.score-card-table th, .score-card-table td { white-space: nowrap; }
`}</style>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 className="mb-0">Student Score Card</h2>
<div className="text-muted small">
{heading}
{student?.school_id ? ` - ID: ${student.school_id}` : null}
</div>
</div>
<Link className="btn btn-outline-secondary" to="/app/student/score-card/list">
Back
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{!error && rows.length === 0 && student ? (
<div className="alert alert-warning">No semester scores found for this student.</div>
) : null}
{rows.length > 0 && student ? (
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<strong>Scores By Year and Semester</strong>
</div>
<div className="card-body table-responsive pt-2">
<table
className="table table-striped table-hover align-middle score-card-table no-mgmt-sticky"
data-no-mgmt-sticky="1"
>
<thead className="table-light">
<tr>
<th>School Year</th>
<th>Year Score</th>
<th>Grade</th>
<th>Semester</th>
<th>Homework Avg</th>
<th>Project Avg</th>
<th>Participation</th>
<th>Test/Quiz Avg</th>
<th>Attendance</th>
<th>PTAP</th>
<th>Midterm</th>
<th>Semester Score</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const sem = (row.semester ?? '').trim().toLowerCase()
const semLabel = sem !== '' ? sem.charAt(0).toUpperCase() + sem.slice(1) : 'N/A'
const semParam = semLabel !== 'N/A' ? semLabel : ''
const testQuiz = row.quiz_avg ?? row.test_avg ?? null
const comments = row.comments ?? {}
const yearKey = String(row.school_year ?? '')
const showYearCells = firstRowIndexByYear.get(yearKey) === i
const rowspan = yearCounts[yearKey] ?? 1
const rowAlt = yearStripe[i] ?? 0
const pdfUrl =
semParam && yearKey && student.id
? reportCardStudentUrl(student.id, {
school_year: yearKey,
semester: semParam,
download: true,
})
: null
const isHighlight =
highlightYear !== '' &&
highlightSem !== '' &&
yearKey === highlightYear &&
sem === highlightSem
return (
<tr
key={`${yearKey}-${sem}-${i}`}
id={`score-card-row-${i}`}
className={`score-year-alt-${rowAlt}${isHighlight ? ' table-warning' : ''}`}
>
{showYearCells ? (
<>
<td rowSpan={rowspan}>{row.school_year ?? 'N/A'}</td>
<td rowSpan={rowspan}>{formatScore(row.year_score ?? null)}</td>
<td rowSpan={rowspan}>{row.class_section_name ?? '—'}</td>
</>
) : null}
<td>
{pdfUrl ? (
<a
href={pdfUrl}
target="_blank"
rel="noopener noreferrer"
className="d-inline-flex align-items-center gap-1"
>
<i className="bi bi-file-earmark-pdf" aria-hidden />
<span>{semLabel}</span>
</a>
) : (
semLabel
)}
</td>
<td>{formatScore(row.homework_avg ?? null)}</td>
<td>{formatScore(row.project_avg ?? null)}</td>
<td>{formatScore(row.participation_score ?? null)}</td>
<td>{formatScore(testQuiz)}</td>
<td>{formatScore(row.attendance_score ?? null)}</td>
<td>{formatScore(row.ptap_score ?? null)}</td>
<td>{formatScore(row.midterm_exam_score ?? null)}</td>
<td>{formatScore(row.semester_score ?? null)}</td>
<td style={{ minWidth: 240 }}>
{comments && typeof comments === 'object' && Object.keys(comments).length > 0 ? (
Object.entries(comments).map(([type, comment]) => (
<details key={type} className="mb-1">
<summary className="small fw-semibold">{labelFromType(type)}</summary>
<div className="small text-muted mt-1">{String(comment)}</div>
</details>
))
) : (
<span className="text-muted">N/A</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
) : null}
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { StudentScoreCardRow } from '../../api/types'
/** Numeric value for sorting displayed semester scores (`—` / non-numeric → NaN). */
export function parseScoreSortLabel(label: string): number {
const s = label.trim()
if (s === '' || s === '—' || s === 'N/A') return Number.NaN
const n = Number.parseFloat(s)
return Number.isFinite(n) ? n : Number.NaN
}
export function formatSemesterScore(val: unknown): string {
if (val === null || val === undefined || val === '') return '—'
if (typeof val === 'number' && Number.isFinite(val)) {
let out = val.toFixed(1)
if (out.endsWith('.0')) out = out.slice(0, -2)
return out
}
if (typeof val === 'string' && val.trim() !== '' && !Number.isNaN(Number(val))) {
const n = Number(val)
let out = n.toFixed(1)
if (out.endsWith('.0')) out = out.slice(0, -2)
return out
}
return String(val)
}
const SEM_SORT = ['fall', 'spring', 'summer']
export function sortSemesterKeys(a: string, b: string): number {
const la = a.toLowerCase()
const lb = b.toLowerCase()
const ia = SEM_SORT.indexOf(la)
const ib = SEM_SORT.indexOf(lb)
if (ia >= 0 && ib >= 0) return ia - ib
if (ia >= 0) return -1
if (ib >= 0) return 1
return la.localeCompare(lb)
}
export function semesterHeaderLabel(key: string): string {
const k = key.trim()
if (!k) return '—'
return k.charAt(0).toUpperCase() + k.slice(1).toLowerCase()
}
/** Rows for a school year (all rows if `year` empty). */
export function rowsForYear(rows: StudentScoreCardRow[], year: string | null): StudentScoreCardRow[] {
const y = year?.trim() ?? ''
if (!y) return rows
return rows.filter((r) => String(r.school_year ?? '').trim() === y)
}
export function scoreForSemester(
rows: StudentScoreCardRow[],
year: string | null,
semesterKey: string,
): string {
const y = year?.trim() ?? ''
const sk = semesterKey.toLowerCase()
const row = rows.find((r) => {
const ry = String(r.school_year ?? '').trim()
const rs = String(r.semester ?? '').trim().toLowerCase()
if (rs !== sk) return false
if (!y) return true
return ry === y
})
if (!row) return '—'
return formatSemesterScore(row.semester_score ?? null)
}