fix teacher, parent and admin pages
This commit is contained in:
@@ -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 page’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user