fix financial and certificates

This commit is contained in:
root
2026-06-05 01:51:12 -04:00
parent 647b96cafc
commit c4d7a06a17
16 changed files with 1689 additions and 374 deletions
+5 -5
View File
@@ -12,8 +12,8 @@ export function LoginPage() {
const locState = location.state as { from?: string; registered?: boolean; message?: string } | null
const from = locState?.from ?? '/app/home'
const flashSuccess =
locState?.registered === true ? locState?.message ?? 'Registration submitted.' : null
const flashMessage = locState?.message ?? null
const flashVariant = locState?.registered === true ? 'success' : 'warning'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -101,9 +101,9 @@ export function LoginPage() {
Login to Your Account
</h3>
{flashSuccess ? (
<div className="alert alert-success text-center" role="alert">
{flashSuccess}
{flashMessage ? (
<div className={`alert alert-${flashVariant} text-center`} role="alert">
{flashMessage}
</div>
) : null}
+72 -33
View File
@@ -20,6 +20,59 @@ type NavFormState = {
roles: string[]
}
const navBuilderCollator = new Intl.Collator(undefined, {
sensitivity: 'base',
numeric: true,
})
function compareLabels(a: string | null | undefined, b: string | null | undefined): number {
return navBuilderCollator.compare(a?.trim() || '', b?.trim() || '')
}
function navParentId(item: NavBuilderItem): number | null {
return item.menu_parent_id ?? item.parent_id ?? null
}
function compareBuilderItems(a: NavBuilderItem, b: NavBuilderItem): number {
const parentResult = compareLabels(a.parent_label ?? '', b.parent_label ?? '')
const labelResult = compareLabels(a.label, b.label)
return parentResult || labelResult || a.id - b.id
}
function sortBuilderItems(items: NavBuilderItem[]): NavBuilderItem[] {
return [...items].sort(compareBuilderItems)
}
function sortParentOptions(options: NavBuilderData['parentOptions']): NavBuilderData['parentOptions'] {
return [...options].sort((a, b) => compareLabels(a.label, b.label) || a.id - b.id)
}
function normalizeBuilderData(data: NavBuilderData): NavBuilderData {
return {
...data,
items: sortBuilderItems(data.items ?? []),
parentOptions: sortParentOptions(data.parentOptions ?? []),
}
}
function alphabeticalOrdersByParent(items: NavBuilderItem[]): Record<string, number> {
const groups = new Map<string, NavBuilderItem[]>()
for (const item of items) {
const key = String(navParentId(item) ?? 'root')
groups.set(key, [...(groups.get(key) ?? []), item])
}
const orders: Record<string, number> = {}
for (const group of groups.values()) {
const sorted = [...group].sort((a, b) => compareLabels(a.label, b.label) || a.id - b.id)
sorted.forEach((item, index) => {
orders[String(item.id)] = index + 1
})
}
return orders
}
const blankForm: NavFormState = {
id: '',
menu_parent_id: '',
@@ -47,7 +100,7 @@ export function NavBuilderPage() {
setLoading(true)
try {
const res = await fetchNavBuilderData()
setPayload(res.data?.data ?? { items: [], roles: [], parentOptions: [] })
setPayload(normalizeBuilderData(res.data?.data ?? { items: [], roles: [], parentOptions: [] }))
setMessages([])
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Failed to load menu items.' }])
@@ -60,8 +113,10 @@ export function NavBuilderPage() {
void load()
}, [])
const sortedItems = useMemo(() => sortBuilderItems(payload.items), [payload.items])
const filteredParentOptions = useMemo(
() => payload.parentOptions.filter((option) => String(option.id) !== form.id),
() => sortParentOptions(payload.parentOptions.filter((option) => String(option.id) !== form.id)),
[form.id, payload.parentOptions],
)
@@ -77,7 +132,7 @@ export function NavBuilderPage() {
url: form.url.trim() || null,
icon_class: form.icon_class.trim() || null,
target: form.target.trim() || null,
sort_order: Number(form.sort_order) || 0,
sort_order: 0,
is_enabled: form.is_enabled,
roles: form.roles.map(Number).filter((id) => id > 0),
})
@@ -105,13 +160,11 @@ export function NavBuilderPage() {
}
async function onSaveOrder() {
const orders = Object.fromEntries(
payload.items.map((item) => [String(item.id), Number(item.sort_order ?? item.order ?? 0)]),
)
const orders = alphabeticalOrdersByParent(payload.items)
setMessages([])
try {
await reorderNavBuilderItems(orders)
setMessages([{ type: 'success', message: 'Menu order saved.' }])
setMessages([{ type: 'success', message: 'Alphabetical menu order saved.' }])
await load()
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to reorder menu items.' }])
@@ -133,6 +186,8 @@ export function NavBuilderPage() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const alphabeticalOrders = useMemo(() => alphabeticalOrdersByParent(sortedItems), [sortedItems])
const editingItem = form.id
? payload.items.find((item) => String(item.id) === form.id) ?? null
: null
@@ -154,7 +209,7 @@ export function NavBuilderPage() {
<div className="card-header d-flex justify-content-between align-items-center">
<span>Current Menu</span>
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void onSaveOrder()}>
Save order
Save alphabetical order
</button>
</div>
<div className="card-body">
@@ -167,7 +222,7 @@ export function NavBuilderPage() {
<th>URL</th>
<th>Parent</th>
<th>Roles</th>
<th>Order</th>
<th>Alphabetical position</th>
<th>Enabled</th>
<th>Depth</th>
</tr>
@@ -182,7 +237,7 @@ export function NavBuilderPage() {
<td colSpan={8} className="text-center text-muted">No menu items found.</td>
</tr>
) : (
payload.items.map((item) => (
sortedItems.map((item) => (
<tr key={item.id}>
<td className="text-nowrap">
<button className="btn btn-sm btn-outline-primary me-1" type="button" onClick={() => editItem(item)}>
@@ -198,7 +253,7 @@ export function NavBuilderPage() {
{item.icon_class ? <code className="ms-2 small">{item.icon_class}</code> : null}
</td>
<td>{item.url ? <code>{item.url}</code> : <span className="text-muted">-</span>}</td>
<td>{parentLabel(item, payload.items)}</td>
<td>{parentLabel(item, sortedItems)}</td>
<td>
{(item.roles?.names ?? []).length > 0 ? (
item.roles?.names?.map((roleName) => (
@@ -211,21 +266,12 @@ export function NavBuilderPage() {
)}
</td>
<td>
<input
className="form-control form-control-sm nav-builder-order"
type="number"
value={item.sort_order ?? item.order ?? 0}
onChange={(event) => {
const next = Number(event.target.value) || 0
setPayload((current) => ({
...current,
items: current.items.map((row) => (row.id === item.id ? { ...row, sort_order: next } : row)),
}))
}}
/>
<span className="badge bg-light text-dark">
{alphabeticalOrders[String(item.id)] ?? '-'}
</span>
</td>
<td>
{Boolean(item.is_enabled ?? item.enabled) ? (
{(item.is_enabled ?? item.enabled) ? (
<span className="badge bg-success">Yes</span>
) : (
<span className="badge bg-light text-dark">No</span>
@@ -312,15 +358,8 @@ export function NavBuilderPage() {
</select>
</div>
<div className="col-md-4 mb-2">
<label className="form-label" htmlFor="sort_order">Order</label>
<input
className="form-control"
type="number"
name="sort_order"
id="sort_order"
value={form.sort_order}
onChange={(event) => setForm((current) => ({ ...current, sort_order: event.target.value }))}
/>
<label className="form-label">Order</label>
<div className="form-control bg-light text-muted">Alphabetical</div>
</div>
<div className="col-md-4 mb-2 d-flex align-items-end">
<div className="form-check">
@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchCertificatesAuditLog, type CertificateAuditPayload } from '../../api/certificates'
function formatDateTime(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
function formatDate(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
}
export function CertificatesAuditLogPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const [data, setData] = useState<CertificateAuditPayload | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetchCertificatesAuditLog({ school_year: schoolYear || undefined })
.then((payload) => {
if (!cancelled) setData(payload)
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load certificate audit log.')
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [schoolYear])
function onYearChange(nextYear: string) {
const next = new URLSearchParams(searchParams)
if (nextYear) next.set('school_year', nextYear)
else next.delete('school_year')
setSearchParams(next, { replace: true })
}
return (
<div className="container-fluid py-4">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 className="mb-0">
<i className="bi bi-journal-check me-2" />
Certificate Audit Log
</h2>
<div className="text-muted small">
Every issued certificate is recorded here for tracking and auditing.
</div>
</div>
<Link to="/app/administrator/certificates" className="btn btn-outline-secondary btn-sm">
<i className="bi bi-award me-1" />
Generate Certificates
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <div className="text-muted">Loading audit log</div> : null}
{(data?.year_summary?.length ?? 0) > 0 ? (
<div className="row g-3 mb-4">
{data?.year_summary.map((row) => (
<div className="col-auto" key={row.school_year}>
<div className="card shadow-sm text-center px-4 py-2" style={{ minWidth: 150 }}>
<div className="fw-bold fs-4">{row.total}</div>
<div className="text-muted small">{row.school_year}</div>
</div>
</div>
))}
</div>
) : null}
<div className="card shadow-sm mb-4">
<div className="card-body py-2">
<div className="row g-2 align-items-center">
<div className="col-auto">
<label className="col-form-label fw-semibold">School Year</label>
</div>
<div className="col-auto">
<select
className="form-select form-select-sm"
value={schoolYear}
onChange={(event) => onYearChange(event.target.value)}
>
<option value=""> All years </option>
{(data?.year_summary ?? []).map((row) => (
<option key={row.school_year} value={row.school_year}>
{row.school_year} ({row.total})
</option>
))}
</select>
</div>
</div>
</div>
</div>
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<span className="fw-semibold">
Issued Certificates <span className="badge bg-secondary ms-1">{data?.records.length ?? 0}</span>
</span>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-hover table-striped align-middle mb-0 no-mgmt-sticky">
<thead className="table-light">
<tr>
<th>Certificate #</th>
<th>Student</th>
<th>Grade</th>
<th>Cert Date</th>
<th>School Year</th>
<th>Issued By</th>
<th>Issued At</th>
</tr>
</thead>
<tbody>
{(data?.records.length ?? 0) === 0 ? (
<tr>
<td colSpan={7} className="text-center text-muted py-4">
No certificates issued yet.
</td>
</tr>
) : (
data?.records.map((record) => (
<tr key={`${record.certificate_number}-${record.issued_at ?? ''}`}>
<td><code>{record.certificate_number}</code></td>
<td>{record.student_name}</td>
<td>{record.grade || '—'}</td>
<td>{formatDate(record.cert_date)}</td>
<td>{record.school_year || '—'}</td>
<td>{`${record.admin_firstname ?? ''} ${record.admin_lastname ?? ''}`.trim() || '—'}</td>
<td>{formatDateTime(record.issued_at)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
+374 -275
View File
@@ -1,320 +1,419 @@
import { type FormEvent, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchCertFormOptions,
fetchCertificateReprint,
fetchCertificatesDashboard,
postCertificateGenerate,
type CertClassSection,
type CertStudent,
type CertificateDashboardPayload,
type CertificateSectionRow,
type CertificateStudentRow,
} from '../../api/certificates'
export function CertificatesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const classId = searchParams.get('class_section_id') ?? ''
const schoolYear = searchParams.get('school_year') ?? ''
function formatScore(value: number | null) {
return value == null ? '—' : value.toFixed(2)
}
const [classSections, setClassSections] = useState<CertClassSection[]>([])
const [students, setStudents] = useState<CertStudent[]>([])
const [currentYear, setCurrentYear] = useState('')
const [loadingStudents, setLoadingStudents] = useState(false)
const [error, setError] = useState<string | null>(null)
function isoToday() {
return new Date().toISOString().slice(0, 10)
}
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [certDate, setCertDate] = useState(() => {
const d = new Date()
return d.toISOString().slice(0, 10)
})
const [generating, setGenerating] = useState(false)
function toDisplayDate(isoDate: string) {
const date = new Date(`${isoDate}T00:00:00`)
if (Number.isNaN(date.getTime())) return isoDate
return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${date.getFullYear()}`
}
const selectAllRef = useRef<HTMLInputElement>(null)
function downloadBlob(blob: Blob) {
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener')
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
function DecisionBadge({ student }: { student: CertificateStudentRow }) {
const colorMap: Record<string, string> = {
Pass: 'success',
'Repeat Class': 'danger',
'Make-up exam in fall': 'info',
'Deferred decision': 'info',
Expel: 'danger',
Withdrawn: 'secondary',
}
if (student.decision_rows.length === 0 || student.decision_state === 'pending') {
return <span className="badge bg-warning text-dark">Pending</span>
}
if (student.decision_labels.length === 0) {
return <span className="text-muted small"></span>
}
return (
<>
{student.decision_labels.map((label) => (
<span key={label} className={`badge bg-${colorMap[label] ?? 'secondary'} me-1`}>
{label}
</span>
))}
</>
)
}
function StatusDot({ hasPass, fullyDone }: { hasPass: boolean; fullyDone: boolean }) {
const className = !hasPass ? 'no-eligible' : fullyDone ? 'done' : 'pending'
return <span className={`cert-status-dot ${className}`} aria-hidden />
}
function CertificateSectionCard({
section,
schoolYear,
defaultCertDate,
onGenerated,
}: {
section: CertificateSectionRow
schoolYear: string
defaultCertDate: string
onGenerated: () => Promise<void> | void
}) {
const eligibleStudents = useMemo(
() => section.students.filter((student) => student.eligible),
[section.students],
)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [certDate, setCertDate] = useState(isoToday())
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Load class sections on mount / school_year change
useEffect(() => {
fetchCertFormOptions({ school_year: schoolYear || undefined })
.then((raw: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = (raw as any)?.data ?? raw
setClassSections(Array.isArray(payload?.class_sections) ? payload.class_sections : [])
setCurrentYear(typeof payload?.school_year === 'string' ? payload.school_year : '')
})
.catch((err: unknown) => {
console.error('[Certificates] form-options error:', err)
if (err instanceof ApiHttpError) {
setError(`API error ${err.status}: ${err.message}`)
} else if (err instanceof Error) {
setError(`Error: ${err.message}`)
} else {
setError('Failed to load classes.')
}
})
}, [schoolYear])
setSelectedIds([])
setCertDate(isoToday())
setError(null)
}, [section.section_id, defaultCertDate])
// Load students when class changes
useEffect(() => {
if (!classId) {
setStudents([])
setSelectedIds(new Set())
const allSelected = eligibleStudents.length > 0 && selectedIds.length === eligibleStudents.length
const partiallySelected = selectedIds.length > 0 && !allSelected
async function handleGenerate(event: FormEvent) {
event.preventDefault()
if (selectedIds.length === 0) {
setError('Please select at least one student.')
return
}
setLoadingStudents(true)
setBusy(true)
setError(null)
fetchCertFormOptions({ class_section_id: classId, school_year: schoolYear || undefined })
.then((raw: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = (raw as any)?.data ?? raw
setStudents(Array.isArray(payload?.students) ? payload.students : [])
setSelectedIds(new Set())
try {
const blob = await postCertificateGenerate({
student_ids: selectedIds,
cert_date: toDisplayDate(certDate),
class_section_id: section.section_id,
school_year: schoolYear,
})
.catch(() => setStudents([]))
.finally(() => setLoadingStudents(false))
}, [classId, schoolYear])
// Keep select-all checkbox tri-state in sync
useEffect(() => {
const el = selectAllRef.current
if (!el) return
if (students.length === 0) {
el.checked = false
el.indeterminate = false
} else if (selectedIds.size === students.length) {
el.checked = true
el.indeterminate = false
} else if (selectedIds.size === 0) {
el.checked = false
el.indeterminate = false
} else {
el.checked = false
el.indeterminate = true
downloadBlob(blob)
await onGenerated()
setSelectedIds([])
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to generate certificates.')
} finally {
setBusy(false)
}
}, [selectedIds, students])
function handleClassChange(value: string) {
const next = new URLSearchParams(searchParams)
if (value) next.set('class_section_id', value)
else next.delete('class_section_id')
setSearchParams(next, { replace: true })
}
function handleSchoolYearChange(value: string) {
const next = new URLSearchParams(searchParams)
if (value) next.set('school_year', value)
else next.delete('school_year')
next.delete('class_section_id')
setSearchParams(next, { replace: true })
async function handleReprint(certificateNumber: string) {
setBusy(true)
setError(null)
try {
const blob = await fetchCertificateReprint(certificateNumber)
downloadBlob(blob)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to reprint certificate.')
} finally {
setBusy(false)
}
}
function toggleStudent(id: number) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
function toggleStudent(studentId: number) {
setSelectedIds((previous) =>
previous.includes(studentId)
? previous.filter((id) => id !== studentId)
: [...previous, studentId],
)
}
function toggleAll(checked: boolean) {
if (checked) setSelectedIds(new Set(students.map((s) => s.id)))
else setSelectedIds(new Set())
setSelectedIds(checked ? eligibleStudents.map((student) => student.student_id) : [])
}
function formatCertDate(isoDate: string): string {
const d = new Date(isoDate + 'T00:00:00')
if (isNaN(d.getTime())) return isoDate
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}/${dd}/${d.getFullYear()}`
}
async function handleGenerate(e: FormEvent) {
e.preventDefault()
if (selectedIds.size === 0) return
setGenerating(true)
setError(null)
try {
const blob = await postCertificateGenerate({
student_ids: Array.from(selectedIds),
cert_date: formatCertDate(certDate),
class_section_id: classId ? Number(classId) : null,
})
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
} catch (err) {
setError(err instanceof ApiHttpError ? err.message : 'Failed to generate certificates.')
} finally {
setGenerating(false)
}
}
const hasStudents = students.length > 0
return (
<div className="container-fluid py-4">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 className="mb-0">
<i className="bi bi-award me-2" />
Generate Certificates
</h2>
<div className="text-muted small">
Select a class, choose students, then generate and print their certificates.
</div>
</div>
</div>
<form className="mb-5" onSubmit={handleGenerate}>
<h4 className="mt-4 mb-2 text-center">{section.section_name}</h4>
{error && (
<div className="alert alert-danger alert-dismissible fade show" role="alert">
{error}
<button
type="button"
className="btn-close"
onClick={() => setError(null)}
aria-label="Close"
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div className="d-flex align-items-center gap-4 flex-wrap">
<span className="fw-semibold">
Students <span className="badge bg-secondary ms-1">{section.student_count}</span>
</span>
<span className="text-muted small">
<strong className="text-success">{section.pass_count}</strong> Pass
</span>
<span className="text-muted small">
<strong className="text-primary">{section.cert_count}</strong> Generated
</span>
<span className="text-muted small">
<strong className={section.remaining_count > 0 ? 'text-warning' : 'text-muted'}>
{section.remaining_count}
</strong>{' '}
Remaining
</span>
</div>
<div className="input-group input-group-sm" style={{ width: 200 }}>
<span className="input-group-text">
<i className="bi bi-calendar3" />
</span>
<input
type="date"
className="form-control"
value={certDate}
onChange={(event) => setCertDate(event.target.value)}
title="Certificate date"
/>
</div>
)}
{/* Filter */}
<div className="card shadow-sm mb-4">
<div className="card-header fw-semibold">Filter Students</div>
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-12 col-md-5">
<label className="form-label fw-semibold mb-1">Class / Section</label>
<select
className="form-select"
value={classId}
onChange={(e) => handleClassChange(e.target.value)}
>
<option value=""> Select a class </option>
{classSections.map((cs) => (
<option key={cs.class_section_id} value={String(cs.class_section_id)}>
{cs.class_section_name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-3">
<label className="form-label fw-semibold mb-1">School Year</label>
<input
type="text"
className="form-control"
placeholder="e.g. 2024-2025"
value={schoolYear || currentYear}
onChange={(e) => handleSchoolYearChange(e.target.value)}
/>
</div>
</div>
</div>
</div>
{loadingStudents && (
<div className="text-muted">Loading students</div>
)}
{!loadingStudents && hasStudents && (
<form onSubmit={handleGenerate}>
<div className="card shadow-sm mb-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span className="fw-semibold">
Students
<span className="badge bg-secondary ms-1">{students.length}</span>
</span>
<div className="d-flex align-items-center gap-3">
<div className="input-group input-group-sm" style={{ width: 200 }}>
<span className="input-group-text">
<i className="bi bi-calendar3" />
</span>
<input
type="date"
className="form-control"
value={certDate}
onChange={(e) => setCertDate(e.target.value)}
title="Certificate date"
/>
</div>
<div className="form-check mb-0">
<div className="table-responsive">
<table className="table table-hover table-striped align-middle mb-0">
<thead className="table-light">
<tr>
<th style={{ width: 40 }} className="text-center">
<input
className="form-check-input"
type="checkbox"
checked={allSelected}
ref={(node) => {
if (node) node.indeterminate = partiallySelected
}}
onChange={(event) => toggleAll(event.target.checked)}
/>
</th>
<th>First Name</th>
<th>Last Name</th>
<th className="text-center">Year Score</th>
<th>Decision</th>
<th>Certificate No.</th>
</tr>
</thead>
<tbody>
{section.students.map((student) => (
<tr key={student.student_id}>
<td className="text-center">
<input
className="form-check-input"
type="checkbox"
id="selectAll"
ref={selectAllRef}
onChange={(e) => toggleAll(e.target.checked)}
checked={selectedIds.includes(student.student_id)}
disabled={!student.eligible || busy}
onChange={() => toggleStudent(student.student_id)}
/>
<label className="form-check-label" htmlFor="selectAll">
Select all
</label>
</div>
</div>
</div>
</td>
<td>{student.firstname}</td>
<td>{student.lastname}</td>
<td className="text-center fw-semibold">{formatScore(student.year_score)}</td>
<td>
<DecisionBadge student={student} />
</td>
<td>
{student.certificate_number ? (
<button
type="button"
className="btn btn-link btn-sm p-0 font-monospace"
disabled={busy}
onClick={() => handleReprint(student.certificate_number as string)}
>
{student.certificate_number}
</button>
) : (
<span className="text-muted"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-hover table-striped align-middle mb-0">
<thead className="table-light">
<tr>
<th style={{ width: 40 }} />
<th>Last Name</th>
<th>First Name</th>
<th>Grade / Class</th>
</tr>
</thead>
<tbody>
{students.map((s) => (
<tr key={s.id}>
<td className="text-center">
<input
className="form-check-input"
type="checkbox"
checked={selectedIds.has(s.id)}
onChange={() => toggleStudent(s.id)}
/>
</td>
<td>{s.lastname}</td>
<td>{s.firstname}</td>
<td>{s.grade}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="d-flex justify-content-between align-items-center mt-2">
<span className="text-muted small">
{selectedIds.length} student{selectedIds.length !== 1 ? 's' : ''} selected
</span>
<button type="submit" className="btn btn-success" disabled={selectedIds.length === 0 || busy}>
{busy ? (
<>
<span className="spinner-border spinner-border-sm me-1" />
Generating...
</>
) : (
<>
<i className="bi bi-printer me-1" />
Generate & Print Certificates
</>
)}
</button>
</div>
</form>
)
}
<div className="card-footer d-flex justify-content-between align-items-center">
<span className="text-muted small">
{selectedIds.size} student{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<button
type="submit"
className="btn btn-success"
disabled={selectedIds.size === 0 || generating}
>
{generating ? (
<>
<span className="spinner-border spinner-border-sm me-1" role="status" />
Generating
</>
) : (
<>
<i className="bi bi-printer me-1" />
Generate &amp; Print Certificates
</>
)}
</button>
export function CertificatesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const groupKey = searchParams.get('group') ?? ''
const [data, setData] = useState<CertificateDashboardPayload | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [yearInput, setYearInput] = useState('')
async function loadDashboard(currentSchoolYear: string) {
setLoading(true)
setError(null)
try {
const payload = await fetchCertificatesDashboard({
school_year: currentSchoolYear || undefined,
})
setData(payload)
setYearInput(payload.school_year || currentSchoolYear)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load certificate dashboard.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadDashboard(schoolYear)
}, [schoolYear])
const groups = data?.grade_groups ?? []
const activeGroupKey =
(groupKey && groups.some((group) => group.key === groupKey) ? groupKey : '') ||
data?.default_group_key ||
groups[0]?.key ||
''
const activeGroup = groups.find((group) => group.key === activeGroupKey) ?? null
function applySchoolYear(event: FormEvent) {
event.preventDefault()
const next = new URLSearchParams(searchParams)
if (yearInput.trim()) next.set('school_year', yearInput.trim())
else next.delete('school_year')
next.delete('group')
setSearchParams(next, { replace: true })
}
function setActiveGroup(nextGroupKey: string) {
const next = new URLSearchParams(searchParams)
next.set('group', nextGroupKey)
if (schoolYear) next.set('school_year', schoolYear)
setSearchParams(next, { replace: true })
}
return (
<div className="container-fluid">
<div className="wrapper">
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-4 mb-3">
<div>
<h2 className="mb-1">
<i className="bi bi-award me-2" />
Generate Certificates
</h2>
<div className="text-muted small">
Students are eligible only when the saved final generated decision is Pass.
</div>
</div>
</form>
)}
{!loadingStudents && classId && !hasStudents && (
<div className="alert alert-warning">
No active students found for the selected class and school year.
<Link className="btn btn-outline-secondary btn-sm" to="/app/administrator/certificates/log">
<i className="bi bi-journal-check me-1" />
Audit Log
</Link>
</div>
)}
{!loadingStudents && !classId && (
<div className="alert alert-info">
Select a class above to load its student roster.
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex justify-content-end mb-3">
<form className="d-flex gap-2 align-items-center" onSubmit={applySchoolYear}>
<label className="form-label mb-0 me-1 text-muted small">School Year</label>
<input
type="text"
className="form-control form-control-sm"
style={{ width: 130 }}
value={yearInput}
onChange={(event) => setYearInput(event.target.value)}
placeholder="e.g. 2024-2025"
/>
<button type="submit" className="btn btn-sm btn-outline-primary">
<i className="bi bi-arrow-repeat me-1" />
Reload
</button>
</form>
</div>
)}
{loading ? <div className="text-muted">Loading certificates</div> : null}
{!loading && groups.length === 0 ? (
<div className="alert alert-info">No classes found for {data?.school_year || schoolYear || 'this year'}.</div>
) : null}
{!loading && groups.length > 0 ? (
<>
<ul className="nav nav-tabs justify-content-center" style={{ flexWrap: 'wrap', rowGap: '.25rem' }}>
{groups.map((group) => (
<li className="nav-item" key={group.key}>
<button
type="button"
className={`nav-link ${group.key === activeGroupKey ? 'active' : ''}`}
onClick={() => setActiveGroup(group.key)}
>
<StatusDot hasPass={group.has_pass} fullyDone={group.fully_done} />
{group.label}
<span className="badge bg-secondary ms-1">{group.total}</span>
</button>
</li>
))}
</ul>
<div className="mt-3">
{activeGroup?.sections.map((section) => (
<CertificateSectionCard
key={section.section_id}
section={section}
schoolYear={data?.school_year ?? schoolYear}
defaultCertDate={data?.cert_date ?? ''}
onGenerated={() => loadDashboard(data?.school_year ?? schoolYear)}
/>
))}
</div>
</>
) : null}
</div>
<style>{`
.cert-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
flex-shrink: 0;
}
.cert-status-dot.done { background-color: #198754; }
.cert-status-dot.pending { background-color: #dc3545; }
.cert-status-dot.no-eligible { background-color: #adb5bd; }
`}</style>
</div>
)
}
@@ -172,7 +172,7 @@ export function DiscountApplyVoucherPage() {
aria-label="Select all"
/>
</th>
<th>Parent ID</th>
<th>School ID</th>
<th>Parent Name</th>
<th>Email</th>
<th>Has Discount?</th>
+1 -1
View File
@@ -143,7 +143,7 @@ export function ReverseDiscountPage() {
<thead>
<tr>
<th>Select</th>
<th>Parent ID</th>
<th>School ID</th>
<th>Name</th>
<th>Email</th>
<th>Has discount?</th>
+549
View File
@@ -0,0 +1,549 @@
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchTrophyFinal,
fetchTrophyProjection,
fetchTrophyWinners,
type TrophyFinalPayload,
type TrophyProjectionPayload,
type TrophyStudent,
type TrophyWinnersClassResult,
type TrophyWinnersPayload,
} from '../../api/trophy'
function formatScore(value?: number | null) {
return value == null || Number.isNaN(value) ? '—' : value.toFixed(1)
}
function isMale(gender?: string | null) {
const normalized = String(gender ?? '').trim().toLowerCase()
return ['male', 'm', 'boy', 'boys'].includes(normalized)
}
function buildSearch(params: { school_year?: string; percentile?: string | number }) {
const qs = new URLSearchParams()
if (params.school_year) qs.set('school_year', String(params.school_year))
if (params.percentile !== undefined && String(params.percentile).trim() !== '') {
qs.set('percentile', String(params.percentile))
}
const value = qs.toString()
return value ? `?${value}` : ''
}
function TrophyHeader({
title,
subtitle,
actions,
}: {
title: string
subtitle: string
actions?: ReactNode
}) {
return (
<div className="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-4">
<div>
<h2 className="mb-1">{title}</h2>
<p className="text-muted mb-0">{subtitle}</p>
</div>
{actions ? <div className="d-flex gap-2 flex-wrap">{actions}</div> : null}
</div>
)
}
function TrophyFilters({
years,
selectedYear,
selectedPercentile,
onSubmit,
}: {
years: string[]
selectedYear: string
selectedPercentile: number
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}) {
return (
<form className="row g-3 align-items-end mb-4" onSubmit={onSubmit}>
<div className="col-md-4 col-lg-3">
<label className="form-label">School Year</label>
<select className="form-select" name="school_year" defaultValue={selectedYear}>
{(years.length > 0 ? years : [selectedYear]).map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</div>
<div className="col-md-4 col-lg-3">
<label className="form-label">Percentile</label>
<div className="input-group">
<input
className="form-control"
type="number"
min="1"
max="99"
step="1"
name="percentile"
defaultValue={Math.round(selectedPercentile)}
/>
<span className="input-group-text">%</span>
</div>
</div>
<div className="col-md-4 col-lg-2">
<button className="btn btn-primary w-100" type="submit">
Apply
</button>
</div>
</form>
)
}
function useTrophySearchParams() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const percentile = searchParams.get('percentile') ?? ''
function applyFilters(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const next = new URLSearchParams()
const year = String(formData.get('school_year') ?? '').trim()
const pct = String(formData.get('percentile') ?? '').trim()
if (year) next.set('school_year', year)
if (pct) next.set('percentile', pct)
setSearchParams(next)
}
return {
schoolYear,
percentile,
applyFilters,
}
}
function SummaryCard({
label,
value,
detail,
tone = 'primary',
}: {
label: string
value: string | number
detail?: string
tone?: 'primary' | 'success' | 'warning' | 'secondary' | 'info' | 'dark'
}) {
return (
<div className="col-6 col-lg-2">
<div className={`card border-${tone} shadow-sm h-100`}>
<div className="card-body py-3">
<div className={`small text-${tone} text-uppercase fw-semibold mb-1`}>{label}</div>
<div className="fs-3 fw-semibold">{value}</div>
{detail ? <div className="small text-muted mt-1">{detail}</div> : null}
</div>
</div>
</div>
)
}
function StudentGenderBadge({ gender }: { gender?: string | null }) {
const male = isMale(gender)
return (
<span
className="badge"
style={{ backgroundColor: male ? '#4A90E2' : '#E47AB0' }}
>
{male ? 'M' : 'F'}
</span>
)
}
export function TrophyProjectionPage() {
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
const [data, setData] = useState<TrophyProjectionPayload | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setError(null)
fetchTrophyProjection({
school_year: schoolYear || undefined,
percentile: percentile || undefined,
})
.then((payload) => {
if (!cancelled) setData(payload)
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load trophy projection.')
}
})
return () => {
cancelled = true
}
}, [schoolYear, percentile])
const selectedYear = data?.selected_year ?? schoolYear
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
const summary = data?.summary ?? {}
return (
<div className="container-fluid">
<TrophyHeader
title="Trophy Projections"
subtitle="Fall scores determine the projected year-end trophy threshold for each class."
actions={
<>
<Link className="btn btn-warning" to={`/app/administrator/trophy_winners${search}`}>
Reveal Winners
</Link>
<Link className="btn btn-success" to={`/app/administrator/trophy_final${search}`}>
Final vs Predicted
</Link>
</>
}
/>
{error ? <div className="alert alert-danger">{error}</div> : null}
<TrophyFilters
years={data?.years ?? []}
selectedYear={selectedYear}
selectedPercentile={selectedPercentile}
onSubmit={applyFilters}
/>
<div className="row g-3 mb-4">
<SummaryCard label="Classes" value={summary.classes ?? 0} tone="secondary" />
<SummaryCard label="Students" value={summary.students ?? 0} tone="primary" />
<SummaryCard label="Scored" value={summary.scored ?? 0} detail={`${summary.pct_scored ?? 0}% roster`} tone="success" />
<SummaryCard label="Trophies" value={summary.trophies ?? 0} detail={`${summary.pct_trophies ?? 0}% roster`} tone="warning" />
<SummaryCard label="Boys" value={summary.boys ?? 0} detail={`${summary.pct_boys ?? 0}%`} tone="info" />
<SummaryCard label="Girls" value={summary.girls ?? 0} detail={`${summary.pct_girls ?? 0}%`} tone="dark" />
</div>
{data && data.class_results.length === 0 ? (
<div className="alert alert-info">No class or fall-score data found for the selected school year.</div>
) : null}
{data?.class_results?.length ? (
<div className="card shadow-sm">
<div className="card-header bg-white fw-semibold">Class Breakdown</div>
<div className="table-responsive">
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
<thead className="table-light">
<tr>
<th>Class</th>
<th className="text-center">Students</th>
<th className="text-center">Scored</th>
<th className="text-center">Trophies</th>
<th className="text-center">Boys</th>
<th className="text-center">Girls</th>
<th className="text-center">Trophy Boys</th>
<th className="text-center">Trophy Girls</th>
<th className="text-end">Threshold</th>
</tr>
</thead>
<tbody>
{data.class_results.map((row) => (
<tr key={row.section_id}>
<td className="fw-semibold">{row.section_name}</td>
<td className="text-center">{row.student_count}</td>
<td className="text-center">{row.scored_count}</td>
<td className="text-center">
<span className="badge text-bg-warning">{row.trophy_count}</span>
</td>
<td className="text-center">
{row.boys} <span className="text-muted small">({row.pct_boys}%)</span>
</td>
<td className="text-center">
{row.girls} <span className="text-muted small">({row.pct_girls}%)</span>
</td>
<td className="text-center">
{row.trophy_boys} <span className="text-muted small">({row.pct_trophy_boys}%)</span>
</td>
<td className="text-center">
{row.trophy_girls} <span className="text-muted small">({row.pct_trophy_girls}%)</span>
</td>
<td className="text-end text-muted">{formatScore(row.threshold)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</div>
)
}
function WinnersTable({
rows,
}: {
rows: TrophyWinnersClassResult[]
}) {
return (
<>
{rows.map((section) => (
<section key={section.section_id} className="card shadow-sm mb-4">
<div className="card-header d-flex flex-wrap justify-content-between gap-2">
<div className="fw-semibold">{section.section_name}</div>
<div className="small text-muted">
Threshold {formatScore(section.threshold)} | {section.winners.length}/{section.student_count} winners
</div>
</div>
<div className="table-responsive">
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
<thead className="table-light">
<tr>
<th style={{ width: 70 }}>#</th>
<th>Name</th>
<th style={{ width: 90 }}>Gender</th>
<th style={{ width: 140 }}>School ID</th>
<th className="text-end" style={{ width: 120 }}>Fall</th>
<th className="text-end" style={{ width: 120 }}>Spring</th>
<th className="text-end" style={{ width: 120 }}>Year</th>
</tr>
</thead>
<tbody>
{section.winners.map((student, index) => (
<tr key={`${section.section_id}-${student.student_id ?? index}`}>
<td>{index + 1}</td>
<td className="fw-semibold">{student.name}</td>
<td><StudentGenderBadge gender={student.gender} /></td>
<td>{student.school_id ?? '—'}</td>
<td className="text-end">{formatScore(student.fall_score)}</td>
<td className="text-end">{formatScore(student.spring_score)}</td>
<td className="text-end">{formatScore(student.year_score)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</>
)
}
export function TrophyWinnersPage() {
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
const [data, setData] = useState<TrophyWinnersPayload | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setError(null)
fetchTrophyWinners({
school_year: schoolYear || undefined,
percentile: percentile || undefined,
})
.then((payload) => {
if (!cancelled) setData(payload)
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load trophy winners.')
}
})
return () => {
cancelled = true
}
}, [schoolYear, percentile])
const selectedYear = data?.selected_year ?? schoolYear
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
const summary = data?.summary ?? {}
return (
<div className="container-fluid">
<TrophyHeader
title="Trophy Winners"
subtitle={`${selectedYear || 'Current year'} projected trophy winners at the ${Math.round(selectedPercentile)}th percentile.`}
actions={
<>
<Link className="btn btn-outline-secondary" to={`/app/administrator/trophy${search}`}>
Back to Projection
</Link>
<Link className="btn btn-success" to={`/app/administrator/trophy_final${search}`}>
Final vs Predicted
</Link>
</>
}
/>
{error ? <div className="alert alert-danger">{error}</div> : null}
<TrophyFilters
years={data?.years ?? []}
selectedYear={selectedYear}
selectedPercentile={selectedPercentile}
onSubmit={applyFilters}
/>
<div className="row g-3 mb-4">
<SummaryCard label="Classes" value={summary.classes ?? 0} tone="secondary" />
<SummaryCard label="Students" value={summary.students ?? 0} tone="primary" />
<SummaryCard label="Winners" value={summary.winners ?? 0} detail={`${summary.pct_winners ?? 0}% roster`} tone="warning" />
<SummaryCard label="Trophy Boys" value={summary.trophy_boys ?? 0} detail={`${summary.pct_trophy_boys ?? 0}% boys`} tone="info" />
<SummaryCard label="Trophy Girls" value={summary.trophy_girls ?? 0} detail={`${summary.pct_trophy_girls ?? 0}% girls`} tone="dark" />
</div>
{data && data.class_results.length === 0 ? (
<div className="alert alert-info">No projected trophy winners found.</div>
) : null}
{data?.class_results?.length ? <WinnersTable rows={data.class_results} /> : null}
</div>
)
}
function StatusBadge({ status }: { status?: string }) {
if (status === 'confirmed') return <span className="badge bg-success">Confirmed</span>
if (status === 'surprise') return <span className="badge bg-info text-dark">Surprise</span>
if (status === 'missed') return <span className="badge text-bg-warning">Missed</span>
return <span className="badge bg-light text-dark border">None</span>
}
function FinalStatusRows({ students }: { students: TrophyStudent[] }) {
return students.filter((student) => student.status && student.status !== 'none')
}
export function TrophyFinalPage() {
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
const [data, setData] = useState<TrophyFinalPayload | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setError(null)
fetchTrophyFinal({
school_year: schoolYear || undefined,
percentile: percentile || undefined,
})
.then((payload) => {
if (!cancelled) setData(payload)
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load final trophy report.')
}
})
return () => {
cancelled = true
}
}, [schoolYear, percentile])
const selectedYear = data?.selected_year ?? schoolYear
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
const summary = data?.summary ?? {}
const winnerGender = data?.winner_gender_summary
return (
<div className="container-fluid">
<TrophyHeader
title="Final vs Predicted"
subtitle="Compares fall-score trophy projections with year-end trophy outcomes."
actions={
<>
<Link className="btn btn-outline-secondary" to={`/app/administrator/trophy${search}`}>
Projection
</Link>
<Link className="btn btn-warning" to={`/app/administrator/trophy_winners${search}`}>
Winners
</Link>
</>
}
/>
{error ? <div className="alert alert-danger">{error}</div> : null}
<TrophyFilters
years={data?.years ?? []}
selectedYear={selectedYear}
selectedPercentile={selectedPercentile}
onSubmit={applyFilters}
/>
<div className="row g-3 mb-4">
<SummaryCard label="Predicted" value={summary.predicted ?? 0} detail={`${summary.pct_predicted ?? 0}% roster`} tone="primary" />
<SummaryCard label="Actual" value={summary.actual ?? 0} detail={`${summary.pct_actual ?? 0}% roster`} tone="warning" />
<SummaryCard label="Confirmed" value={summary.confirmed ?? 0} detail={`${summary.pct_confirmed_of_predicted ?? 0}% of predicted`} tone="success" />
<SummaryCard label="Surprises" value={summary.surprises ?? 0} tone="info" />
<SummaryCard label="Missed" value={summary.missed ?? 0} tone="dark" />
<SummaryCard label="Accuracy" value={`${summary.accuracy ?? 0}%`} tone="secondary" />
</div>
{winnerGender ? (
<div className="card shadow-sm mb-4">
<div className="card-header bg-white fw-semibold">Winner Gender Breakdown</div>
<div className="card-body">
<div className="row g-3">
<SummaryCard label="Total Winners" value={winnerGender.total} tone="secondary" />
<SummaryCard label="Boys" value={winnerGender.boys} detail={`${winnerGender.pct_boys}%`} tone="info" />
<SummaryCard label="Girls" value={winnerGender.girls} detail={`${winnerGender.pct_girls}%`} tone="dark" />
</div>
</div>
</div>
) : null}
{data && data.class_results.length === 0 ? (
<div className="alert alert-info">No data found for the selected school year.</div>
) : null}
{data?.class_results?.map((section) => {
const rows = FinalStatusRows({ students: section.students })
return (
<section key={section.section_id} className="card shadow-sm mb-4">
<div className="card-header d-flex flex-wrap justify-content-between gap-2">
<div className="fw-semibold">{section.section_name}</div>
<div className="small text-muted">
Fall {formatScore(section.fall_threshold)} | Year {formatScore(section.year_threshold)} | Accuracy {section.accuracy}%
</div>
</div>
<div className="table-responsive">
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
<thead className="table-light">
<tr>
<th>Name</th>
<th style={{ width: 90 }}>Gender</th>
<th className="text-end" style={{ width: 110 }}>Fall</th>
<th className="text-end" style={{ width: 110 }}>Spring</th>
<th className="text-end" style={{ width: 110 }}>Year</th>
<th className="text-center" style={{ width: 110 }}>Predicted</th>
<th className="text-center" style={{ width: 110 }}>Actual</th>
<th className="text-center" style={{ width: 120 }}>Status</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="text-center text-muted py-4">
No trophy changes in this class.
</td>
</tr>
) : (
rows.map((student) => (
<tr key={`${section.section_id}-${student.student_id}`}>
<td className="fw-semibold">{student.name}</td>
<td><StudentGenderBadge gender={student.gender} /></td>
<td className="text-end">{formatScore(student.fall_score)}</td>
<td className="text-end">{formatScore(student.spring_score)}</td>
<td className="text-end">{formatScore(student.year_score)}</td>
<td className="text-center">{student.predicted ? 'Yes' : 'No'}</td>
<td className="text-center">{student.actual ? 'Yes' : 'No'}</td>
<td className="text-center"><StatusBadge status={student.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)
})}
</div>
)
}