Files
alrahma_web_client/src/pages/AdministratorToolPages.tsx
T
2026-06-07 20:01:56 -04:00

4941 lines
205 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import './AdminDailyAttendance.css'
import { ApiHttpError } from '../api/http'
import { CiAcademicFilter } from '../components/CiPartials'
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
import {
assignTeacherClass,
assignStudentClass,
autoDistributeStudents,
banIp,
createSubjectCurriculum,
createAdministratorCalendarEvent,
createClassSection,
deleteSubjectCurriculum,
deleteAdministratorCalendarEvent,
deleteClassSection,
deleteLateSlipLog,
deleteTeacherClassAssignment,
deleteUser,
fetchAdministratorCalendarEvent,
fetchAdministratorCalendarEvents,
fetchAdministratorDailyAttendance,
fetchBroadcastEmailOptions,
fetchClassAssignmentOverview,
fetchClassSections,
fetchExamDraftAdminData,
fetchFamilyAdminIndex,
fetchGradingOverview,
fetchIpBans,
fetchLateSlipLogs,
fetchNotificationAlerts,
fetchPaypalTransactions,
fetchPaypalTransactionsCsv,
fetchPrintNotificationRecipients,
fetchSchoolCalendarOptions,
fetchRemovedStudents,
fetchStudentDirectory,
fetchStudentEmergencyContacts,
fetchStudentParent,
fetchStudentPromotionTotals,
fetchSubjectCurriculum,
fetchStudentAssignments,
fetchTeacherClassAssignments,
fetchTeacherSubmissions,
fetchUsers,
notifyTeacherSubmissions,
openProtectedApiFile,
removeStudentClass,
reviewExamDraft,
saveNotificationAlerts,
savePrintNotificationRecipients,
searchAdministratorDashboard,
sendBroadcastEmail,
setStudentActive,
unbanIp,
uploadLegacyExamDraft,
updateAdministratorAttendance,
updateAdministratorCalendarEvent,
updateClassSection,
updateStudentProfile,
updateSubjectCurriculum,
} from '../api/session'
import type {
AdministratorDailyAttendanceEntry,
AdministratorDailyAttendanceSection,
AdministratorDailyAttendanceStudent,
AdministratorDailyAttendanceSummary,
AdministratorDashboardSearchResponse,
BroadcastEmailParentOption,
ClassSectionRow,
ExamDraftRow,
GradingOverviewStudentRow,
NotificationAdminRow,
RemovedStudentRow,
SchoolCalendarDetailRow,
SchoolCalendarEventRow,
StudentDirectoryRow,
StudentAssignmentOption,
StudentAssignmentRow,
StudentPromotionTotalsRow,
SubjectCurriculumEntryRow,
TeacherClassAssignmentRow,
TeacherClassOption,
TeacherSubmissionReportRow,
UserListRow,
} from '../api/types'
function AdminParityShell({
title,
children,
showTitle = true,
}: {
title: string
children: ReactNode
showTitle?: boolean
}) {
return (
<div className="container-fluid py-3">
{showTitle ? (
<div className="mb-4">
<h2 className="h4 mb-1">{title}</h2>
</div>
) : null}
{children}
</div>
)
}
function formatDate(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
}
function formatDateInput(value?: string | null) {
return value ? String(value).slice(0, 10) : ''
}
function SortTh({
col, label, sortCol, sortDir, onSort, style,
}: {
col: string; label: string; sortCol: string; sortDir: 'asc' | 'desc'
onSort: (c: string) => void; style?: React.CSSProperties
}) {
const active = sortCol === col
return (
<th style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }} onClick={() => onSort(col)}>
{label}{' '}
<span style={{ opacity: active ? 1 : 0.3 }}>{active && sortDir === 'desc' ? '▼' : '▲'}</span>
</th>
)
}
function normalizeAttendanceSearch(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
}
function attendanceSearchMatchesPrefix(query: string, values: string[]) {
if (query === '') return true
return values.some((value) => {
const normalized = normalizeAttendanceSearch(value)
if (normalized.startsWith(query)) return true
return normalized.split(' ').some((part) => part.startsWith(query))
})
}
/** MM-DD-YYYY for table headers (matches legacy attendance grid). */
function formatAttendanceMdY(value?: string | null) {
if (!value) return '—'
const s = String(value).slice(0, 10)
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
if (m) return `${m[2]}-${m[3]}-${m[1]}`
return formatDate(value)
}
function nextSundayAfter(dateStr: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return null
const d = new Date(dateStr + 'T00:00:00')
if (isNaN(d.getTime())) return null
const daysUntil = d.getDay() === 0 ? 7 : 7 - d.getDay()
d.setDate(d.getDate() + daysUntil)
return d.toISOString().slice(0, 10)
}
function attendanceEntryIsReported(isReported: string | null | undefined) {
const v = String(isReported ?? '')
.trim()
.toLowerCase()
return v === 'yes' || v === '1' || v === 'true'
}
function entryToAttendanceSelectValue(entry?: { status: string; isReported?: boolean }) {
if (!entry) return ''
const s = String(entry.status ?? '').toLowerCase()
if (s === 'present') return 'present'
if (s === 'absent') return entry.isReported ? 'absent_reported' : 'absent'
if (s === 'late') return entry.isReported ? 'late_reported' : 'late'
return ''
}
function attendanceCellSelectClass(val: string) {
const base = 'form-select form-select-sm att-cell-select'
if (val === 'present') return `${base} att-sel-present`
if (val === 'absent') return `${base} att-sel-absent`
if (val === 'absent_reported') return `${base} att-sel-absent-reported`
if (val === 'late_reported') return `${base} att-sel-late-reported`
if (val === 'late') return `${base} att-sel-late`
return `${base} att-sel-empty`
}
function monthLabel(value: string) {
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString(undefined, { month: 'long', year: 'numeric' })
}
function eventAudienceBadges(row: SchoolCalendarDetailRow | SchoolCalendarEventRow) {
const props: Record<string, unknown> =
'extendedProps' in row
? { ...(row.extendedProps ?? {}) }
: {
notify_parent: (row as SchoolCalendarDetailRow).notify_parent,
notify_teacher: (row as SchoolCalendarDetailRow).notify_teacher,
notify_admin: (row as SchoolCalendarDetailRow).notify_admin,
}
return [
props.notify_parent ? 'Parents' : null,
props.notify_teacher ? 'Teachers' : null,
props.notify_admin ? 'Admins' : null,
].filter(Boolean) as string[]
}
type AttendanceSectionSnapshot = {
sectionId: string
classId: number
className: string
students: Array<{
id: number
schoolId: string
name: string
summary: { present: number; late: number; absent: number; total: number }
entriesByDate: Record<string, { status: string; reason?: string | null; isReported?: boolean }>
}>
dates: string[]
}
type AttendanceRenderedStudentRow = {
sectionId: string
sectionName: string
student: AttendanceSectionSnapshot['students'][number]
}
type AttendanceGradeGroup = {
key: string
label: string
order: number
sections: AttendanceSectionSnapshot[]
}
function recordPick<T>(map: Record<string, T> | undefined, id: string): T | undefined {
if (!map) return undefined
if (map[id] !== undefined) return map[id]
const n = Number(id)
if (!Number.isNaN(n) && map[String(n)] !== undefined) return map[String(n)]
return undefined
}
function buildAttendanceSections(data: Awaited<ReturnType<typeof fetchAdministratorDailyAttendance>>): AttendanceSectionSnapshot[] {
const raw = data as unknown as Record<string, unknown>
const grades = (data.grades ?? raw.Grades ?? raw.grade_sections ?? raw.sections) as
| Record<string, AdministratorDailyAttendanceSection[]>
| AdministratorDailyAttendanceSection[]
| undefined
const gradesEntries = Array.isArray(grades)
? grades.map((row, idx) => [String(idx), [row]] as const)
: Object.entries(grades ?? {})
const studentsBySection = (data.students_by_section ?? raw.studentsBySection ?? {}) as Record<
string,
AdministratorDailyAttendanceStudent[]
>
const attendanceData = (data.attendance_data ?? raw.attendanceData ?? {}) as Record<
string,
Record<string, AdministratorDailyAttendanceEntry[]>
>
const attendanceRecord = (data.attendance_record ?? raw.attendanceRecord ?? {}) as Record<
string,
Record<string, AdministratorDailyAttendanceSummary>
>
const datesBySection = (data.dates_by_section ?? raw.datesBySection ?? {}) as Record<string, string[]>
const globalDateList = Array.isArray(data.date_list)
? data.date_list.filter((x): x is string => typeof x === 'string' && x.length > 0)
: Array.isArray(raw.dateList)
? (raw.dateList as unknown[]).filter((x): x is string => typeof x === 'string' && x.length > 0)
: []
const sections: AttendanceSectionSnapshot[] = []
for (const [classIdKey, sectionRows] of gradesEntries) {
for (const row of sectionRows ?? []) {
const rowIds = row as AdministratorDailyAttendanceSection & { section_id?: number | string }
const sectionId = String(row.class_section_id ?? rowIds.section_id ?? row.id ?? '')
if (!sectionId) continue
const rowExtra = row as AdministratorDailyAttendanceSection & {
students?: AdministratorDailyAttendanceStudent[]
}
let studentSource = recordPick(studentsBySection, sectionId)
if (!studentSource?.length && Array.isArray(rowExtra.students)) {
studentSource = rowExtra.students
}
const sectionAttendance = recordPick(attendanceData, sectionId)
const sectionSummaryMap = recordPick(attendanceRecord, sectionId)
const students = (studentSource ?? []).map((student) => {
const studentId = Number(student.id)
const entries = sectionAttendance?.[String(studentId)] ?? []
const summary = sectionSummaryMap?.[String(studentId)] ?? {}
return {
id: studentId,
schoolId: student.school_id ?? '—',
name: `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim() || `Student ${studentId}`,
summary: {
present: Number(summary.total_presence ?? 0),
late: Number(summary.total_late ?? 0),
absent: Number(summary.total_absence ?? 0),
total: Number(summary.total_attendance ?? 0),
},
entriesByDate: Object.fromEntries(
entries.map((entry) => [
String(entry.date ?? ''),
{
status: String(entry.status ?? ''),
reason: entry.reason ?? null,
isReported: attendanceEntryIsReported(entry.is_reported ?? null),
},
]),
),
}
})
if (students.length === 0) continue
const perSectionDates = recordPick(datesBySection, sectionId) ?? []
const dates = perSectionDates.length > 0 ? perSectionDates : globalDateList
sections.push({
sectionId,
classId: Number(classIdKey),
className: row.class_section_name ?? `Section ${sectionId}`,
students,
dates,
})
}
}
return sections.sort((a, b) => {
if (a.classId !== b.classId) return a.classId - b.classId
return a.className.localeCompare(b.className)
})
}
function buildAttendanceGradeGroups(sections: AttendanceSectionSnapshot[]): AttendanceGradeGroup[] {
const groups = new Map<string, AttendanceGradeGroup>()
function bucketFor(section: AttendanceSectionSnapshot): { key: string; label: string; order: number } {
const name = section.className
if (/\bkg\b/i.test(name)) return { key: 'kg', label: 'KG', order: 0 }
if (/\byouth\b/i.test(name)) return { key: 'youth', label: 'Youth', order: 99 }
if (/\barabic\b/i.test(name)) return { key: 'arabic', label: 'Arabic', order: 100 }
const gradeMatch = /\bgrade\s*(\d+)\b/i.exec(name)
if (gradeMatch) {
const n = Number(gradeMatch[1])
return { key: `g${n}`, label: `Grade ${n}`, order: n }
}
if (section.classId === 0) return { key: 'kg', label: 'KG', order: 0 }
if (section.classId === 13) return { key: 'youth', label: 'Youth', order: 99 }
return { key: `g${section.classId}`, label: `Grade ${section.classId}`, order: section.classId }
}
for (const section of sections) {
const bucket = bucketFor(section)
const existing = groups.get(bucket.key) ?? { ...bucket, sections: [] }
existing.sections.push(section)
groups.set(bucket.key, existing)
}
return Array.from(groups.values())
.map((group) => ({
...group,
sections: group.sections.sort((a, b) => a.className.localeCompare(b.className)),
}))
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
}
function buildAttendanceAnalysis(sections: AttendanceSectionSnapshot[]) {
const overall = { present: 0, late: 0, absent: 0 }
const byClass = new Map<string, { present: number; late: number; absent: number; totalStudents: number }>()
const studentsNeedingAttention: Array<{
name: string
schoolId: string
className: string
absent: number
late: number
}> = []
for (const section of sections) {
if (!byClass.has(section.className)) {
byClass.set(section.className, { present: 0, late: 0, absent: 0, totalStudents: section.students.length })
}
const bucket = byClass.get(section.className)!
for (const student of section.students) {
overall.present += student.summary.present
overall.late += student.summary.late
overall.absent += student.summary.absent
bucket.present += student.summary.present
bucket.late += student.summary.late
bucket.absent += student.summary.absent
if (student.summary.absent > 0 || student.summary.late > 0) {
studentsNeedingAttention.push({
name: student.name,
schoolId: student.schoolId,
className: section.className,
absent: student.summary.absent,
late: student.summary.late,
})
}
}
}
studentsNeedingAttention.sort((a, b) => {
if (b.absent !== a.absent) return b.absent - a.absent
if (b.late !== a.late) return b.late - a.late
return a.name.localeCompare(b.name)
})
return { overall, byClass: Array.from(byClass.entries()), studentsNeedingAttention }
}
export function AdminBroadcastEmailPage() {
const [parents, setParents] = useState<BroadcastEmailParentOption[]>([])
const [fromOptions, setFromOptions] = useState<{ key: string; label: string }[]>([])
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [filter, setFilter] = useState('')
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState({
from_key: '',
mode: 'personalized' as 'personalized' | 'standard',
subject: '',
body_html: '',
wrap_layout: true,
preheader: '',
cta_text: '',
cta_url: '',
test_email: '',
})
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchBroadcastEmailOptions()
if (cancelled) return
setParents(data.parents ?? [])
setFromOptions(data.fromOptions ?? [])
setForm((current) => ({
...current,
from_key: current.from_key || data.fromOptions?.[0]?.key || '',
}))
setError(null)
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unable to load broadcast email options.')
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const filteredParents = useMemo(() => {
const q = filter.trim().toLowerCase()
if (!q) return parents
return parents.filter((row) => {
const haystack = `${row.firstname ?? ''} ${row.lastname ?? ''} ${row.email ?? ''} ${row.school_id ?? ''}`.toLowerCase()
return haystack.includes(q)
})
}, [filter, parents])
function toggleSelected(id: number) {
setSelectedIds((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id],
)
}
function selectAllFiltered() {
setSelectedIds(Array.from(new Set([...selectedIds, ...filteredParents.map((row) => row.id)])))
}
async function submit(sendTestOnly: boolean) {
setSubmitting(true)
setMessage(null)
setError(null)
try {
const result = await sendBroadcastEmail({
...form,
send_test_only: sendTestOnly,
parent_ids: sendTestOnly ? [] : selectedIds,
})
if (!result.ok) {
setError(result.message ?? 'Broadcast failed.')
} else {
setMessage(result.message ?? 'Broadcast sent.')
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Broadcast failed.')
} finally {
setSubmitting(false)
}
}
return (
<AdminParityShell title="Broadcast Email to Parents">
{message ? <div className="alert alert-success">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card mb-3">
<div className="card-header">Email Details</div>
<div className="card-body row g-3">
<div className="col-md-6">
<label className="form-label">From</label>
<select
className="form-select"
value={form.from_key}
onChange={(event) => setForm((current) => ({ ...current, from_key: event.target.value }))}
disabled={loading || submitting}
>
{fromOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-md-6">
<label className="form-label">Send Mode</label>
<select
className="form-select"
value={form.mode}
onChange={(event) =>
setForm((current) => ({
...current,
mode: event.target.value as 'personalized' | 'standard',
}))
}
disabled={submitting}
>
<option value="personalized">Personalized</option>
<option value="standard">Standard</option>
</select>
</div>
<div className="col-md-8">
<label className="form-label">Subject</label>
<input
className="form-control"
value={form.subject}
onChange={(event) => setForm((current) => ({ ...current, subject: event.target.value }))}
disabled={submitting}
/>
</div>
<div className="col-md-4 d-flex align-items-end">
<div className="form-check">
<input
id="wrap_layout"
className="form-check-input"
type="checkbox"
checked={form.wrap_layout}
onChange={(event) =>
setForm((current) => ({ ...current, wrap_layout: event.target.checked }))
}
disabled={submitting}
/>
<label className="form-check-label" htmlFor="wrap_layout">
Wrap with email layout
</label>
</div>
</div>
<div className="col-12">
<label className="form-label">Message (HTML)</label>
<textarea
className="form-control"
rows={10}
value={form.body_html}
onChange={(event) => setForm((current) => ({ ...current, body_html: event.target.value }))}
placeholder="Dear {{name}}, ..."
disabled={submitting}
/>
</div>
<div className="col-md-6">
<label className="form-label">Preheader</label>
<input
className="form-control"
value={form.preheader}
onChange={(event) => setForm((current) => ({ ...current, preheader: event.target.value }))}
disabled={submitting}
/>
</div>
<div className="col-md-3">
<label className="form-label">CTA Text</label>
<input
className="form-control"
value={form.cta_text}
onChange={(event) => setForm((current) => ({ ...current, cta_text: event.target.value }))}
disabled={submitting}
/>
</div>
<div className="col-md-3">
<label className="form-label">CTA URL</label>
<input
className="form-control"
value={form.cta_url}
onChange={(event) => setForm((current) => ({ ...current, cta_url: event.target.value }))}
disabled={submitting}
/>
</div>
</div>
<div className="card-footer d-flex flex-wrap gap-2 align-items-center">
<div className="input-group" style={{ maxWidth: 420 }}>
<span className="input-group-text">Test email</span>
<input
className="form-control"
type="email"
value={form.test_email}
onChange={(event) => setForm((current) => ({ ...current, test_email: event.target.value }))}
disabled={submitting}
/>
<button
className="btn btn-outline-primary"
type="button"
onClick={() => submit(true)}
disabled={submitting}
>
Send Test Only
</button>
</div>
<span className="ms-auto fw-semibold">Selected: {selectedIds.length}</span>
<button
className="btn btn-primary"
type="button"
onClick={() => submit(false)}
disabled={submitting || selectedIds.length === 0}
>
Send Broadcast
</button>
</div>
</div>
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<span>Parents</span>
<div className="d-flex gap-2">
<input
className="form-control form-control-sm"
style={{ minWidth: 260 }}
placeholder="Filter by name, email, school ID"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={selectAllFiltered}>
Select Filtered
</button>
<button
className="btn btn-sm btn-outline-secondary"
type="button"
onClick={() => setSelectedIds([])}
>
Clear
</button>
</div>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-striped table-hover align-middle mb-0">
<thead className="table-dark">
<tr>
<th style={{ width: 48 }}>#</th>
<th>Name</th>
<th>Email</th>
<th>School ID</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="text-muted">Loading parents</td>
</tr>
) : filteredParents.length === 0 ? (
<tr>
<td colSpan={4} className="text-muted">No parents found.</td>
</tr>
) : (
filteredParents.map((row) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedIds.includes(row.id)}
onChange={() => toggleSelected(row.id)}
/>
</td>
<td>{`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || '—'}</td>
<td>{row.email ?? '—'}</td>
<td>{row.school_id ?? '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</AdminParityShell>
)
}
function CalendarEventForm({
title,
initial,
submitLabel,
onSubmit,
submitting,
}: {
title: string
initial: Partial<SchoolCalendarDetailRow> & {
event_types?: string[]
defaults?: { school_year?: string | null; semester?: string | null }
}
submitLabel: string
onSubmit: (payload: Record<string, unknown>) => Promise<void>
submitting: boolean
}) {
const navigate = useNavigate()
const [form, setForm] = useState({
title: initial.title ?? '',
description: initial.description ?? '',
event_type: initial.event_type ?? '',
date: formatDateInput(initial.date),
notify_parent: Boolean(initial.notify_parent),
notify_teacher: Boolean(initial.notify_teacher),
notify_admin: Boolean(initial.notify_admin),
no_school: Boolean(initial.no_school),
send_email_parent: false,
send_email_teacher: false,
send_email_admin: false,
school_year: initial.school_year ?? initial.defaults?.school_year ?? '',
semester: initial.semester ?? initial.defaults?.semester ?? '',
})
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setForm({
title: initial.title ?? '',
description: initial.description ?? '',
event_type: initial.event_type ?? '',
date: formatDateInput(initial.date),
notify_parent: Boolean(initial.notify_parent),
notify_teacher: Boolean(initial.notify_teacher),
notify_admin: Boolean(initial.notify_admin),
no_school: Boolean(initial.no_school),
send_email_parent: false,
send_email_teacher: false,
send_email_admin: false,
school_year: initial.school_year ?? initial.defaults?.school_year ?? '',
semester: initial.semester ?? initial.defaults?.semester ?? '',
})
}, [initial])
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setError(null)
try {
await onSubmit(form)
} catch (e) {
setError(e instanceof Error ? e.message : `Unable to ${submitLabel.toLowerCase()}.`)
}
}
return (
<AdminParityShell title={title}>
{error ? <div className="alert alert-danger">{error}</div> : null}
<form className="card" onSubmit={handleSubmit}>
<div className="card-body row g-3">
<div className="col-12">
<div className="form-check form-switch mb-2">
<input
id="no_school"
className="form-check-input"
type="checkbox"
checked={form.no_school}
onChange={(event) => setForm((current) => ({ ...current, no_school: event.target.checked }))}
/>
<label className="form-check-label" htmlFor="no_school">
No School Day
</label>
</div>
<label className="form-label">Title</label>
<input
className="form-control"
value={form.title}
onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
required
/>
</div>
<div className="col-12">
<label className="form-label">Description</label>
<textarea
className="form-control"
rows={4}
value={form.description}
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
/>
</div>
<div className="col-md-4">
<label className="form-label">Event Type</label>
<select
className="form-select"
value={form.event_type}
onChange={(event) => setForm((current) => ({ ...current, event_type: event.target.value }))}
>
<option value=""> Select event type </option>
{(initial.event_types ?? []).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div className="col-md-4">
<label className="form-label">Event Date</label>
<input
className="form-control"
type="date"
value={form.date}
onChange={(event) => setForm((current) => ({ ...current, date: event.target.value }))}
required
/>
</div>
<div className="col-md-4">
<label className="form-label">School Year</label>
<input
className="form-control"
value={form.school_year}
onChange={(event) => setForm((current) => ({ ...current, school_year: event.target.value }))}
/>
</div>
<div className="col-md-4">
<label className="form-label">Semester</label>
<select
className="form-select"
value={form.semester}
onChange={(event) => setForm((current) => ({ ...current, semester: event.target.value }))}
>
<option value=""></option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="col-md-8">
<label className="form-label d-block">Calendar Display</label>
<div className="d-flex gap-3 flex-wrap">
<label className="form-check-label">
<input
className="form-check-input me-1"
type="checkbox"
checked={form.notify_parent}
onChange={(event) =>
setForm((current) => ({ ...current, notify_parent: event.target.checked }))
}
/>
Parents
</label>
<label className="form-check-label">
<input
className="form-check-input me-1"
type="checkbox"
checked={form.notify_teacher}
onChange={(event) =>
setForm((current) => ({ ...current, notify_teacher: event.target.checked }))
}
/>
Teachers
</label>
<label className="form-check-label">
<input
className="form-check-input me-1"
type="checkbox"
checked={form.notify_admin}
onChange={(event) =>
setForm((current) => ({ ...current, notify_admin: event.target.checked }))
}
/>
Admins
</label>
</div>
</div>
<div className="col-12">
<label className="form-label d-block">Email Notifications</label>
<div className="d-flex gap-3 flex-wrap">
<label className="form-check-label">
<input
className="form-check-input me-1"
type="checkbox"
checked={form.send_email_parent}
onChange={(event) =>
setForm((current) => ({ ...current, send_email_parent: event.target.checked }))
}
/>
Send to Parents
</label>
<label className="form-check-label">
<input
className="form-check-input me-1"
type="checkbox"
checked={form.send_email_teacher}
onChange={(event) =>
setForm((current) => ({ ...current, send_email_teacher: event.target.checked }))
}
/>
Send to Teachers
</label>
<label className="form-check-label">
<input
className="form-check-input me-1"
type="checkbox"
checked={form.send_email_admin}
onChange={(event) =>
setForm((current) => ({ ...current, send_email_admin: event.target.checked }))
}
/>
Send to Admins
</label>
</div>
</div>
</div>
<div className="card-footer d-flex gap-2 justify-content-end">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/app/administrator/events')}>
Cancel
</button>
<button className="btn btn-primary" type="submit" disabled={submitting}>
{submitLabel}
</button>
</div>
</form>
</AdminParityShell>
)
}
export function AdminCalendarAddEventPage() {
const navigate = useNavigate()
const [options, setOptions] = useState<{ event_types: string[]; defaults: { school_year?: string | null; semester?: string | null } } | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchSchoolCalendarOptions()
if (!cancelled) setOptions(data.data ?? null)
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
if (loading && !options) {
return <AdminParityShell title="Add Event"><p className="text-muted">Loading</p></AdminParityShell>
}
return (
<CalendarEventForm
title="Add Event"
initial={{
event_types: options?.event_types ?? [],
defaults: options?.defaults,
}}
submitLabel="Add Event"
submitting={false}
onSubmit={async (payload) => {
await createAdministratorCalendarEvent(payload as never)
navigate('/app/administrator/events')
}}
/>
)
}
export function AdminCalendarEditPage() {
const navigate = useNavigate()
const { eventId } = useParams()
const [options, setOptions] = useState<{ event_types: string[]; defaults: { school_year?: string | null; semester?: string | null } } | null>(null)
const [event, setEvent] = useState<SchoolCalendarDetailRow | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const [optionsRes, eventRes] = await Promise.all([
fetchSchoolCalendarOptions(),
fetchAdministratorCalendarEvent(Number(eventId)),
])
if (cancelled) return
setOptions(optionsRes.data ?? null)
setEvent(eventRes.data?.event ?? null)
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [eventId])
if (loading || !event) {
return <AdminParityShell title="Edit Event"><p className="text-muted">Loading</p></AdminParityShell>
}
return (
<CalendarEventForm
title="Edit Event"
initial={{
...event,
event_types: options?.event_types ?? [],
defaults: options?.defaults,
}}
submitLabel="Update Event"
submitting={false}
onSubmit={async (payload) => {
await updateAdministratorCalendarEvent(Number(eventId), payload)
navigate('/app/administrator/events')
}}
/>
)
}
/** School calendar admin — `GET /api/v1/settings/school-calendar/events` (JWT). SPA: `/app/administrator/events` or `calendar_view`. */
export function AdminCalendarViewPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [events, setEvents] = useState<SchoolCalendarEventRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const schoolYear = searchParams.get('school_year') ?? ''
const { options, currentYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
if (!schoolYear && currentYear) {
setSearchParams(new URLSearchParams({ school_year: currentYear }), { replace: true })
}
}, [currentYear, schoolYear, setSearchParams])
async function load() {
setLoading(true)
try {
const data = await fetchAdministratorCalendarEvents({
schoolYear: schoolYear || null,
semester: null,
})
setEvents(data.data?.events ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load calendar events.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [schoolYear])
async function onDelete(eventId: number) {
if (!window.confirm('Delete this event?')) return
await deleteAdministratorCalendarEvent(eventId)
await load()
}
return (
<AdminParityShell title="Event list">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex gap-2 mb-3 flex-wrap justify-content-between align-items-center">
<Link to="/app/administrator/calendar" className="btn btn-outline-secondary">
Browse calendar
</Link>
<Link
to={`/app/administrator/calendar_add_event${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}
className="btn btn-success"
>
Add New Event
</Link>
</div>
<form
className="mb-3 d-flex gap-2 justify-content-center align-items-end flex-wrap"
onSubmit={(event) => {
event.preventDefault()
const year = String(new FormData(event.currentTarget).get('school_year') ?? '').trim()
const next = new URLSearchParams()
if (year) next.set('school_year', year)
setSearchParams(next)
}}
>
<div>
<label htmlFor="cal-school-year" className="form-label">School Year</label>
<select
id="cal-school-year"
name="school_year"
className="form-select"
defaultValue={schoolYear || currentYear}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="d-flex gap-2 align-self-end">
<button className="btn btn-secondary" type="submit">Apply</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() =>
setSearchParams(new URLSearchParams(currentYear ? { school_year: currentYear } : {}))
}
>
Reset
</button>
</div>
</form>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Type</th>
<th>Description</th>
<th>Date</th>
<th>Audience</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-muted">Loading events</td></tr>
) : events.length === 0 ? (
<tr><td colSpan={7} className="text-muted">No events found.</td></tr>
) : (
events.map((row) => (
<tr key={row.id ?? row.title + row.start}>
<td>{row.id ?? '—'}</td>
<td>{row.title}</td>
<td>{String(row.extendedProps?.event_type ?? '—')}</td>
<td>{row.description || '—'}</td>
<td>{formatDate(row.start)}</td>
<td>{eventAudienceBadges(row).join(', ') || '—'}</td>
<td className="d-flex gap-2 flex-wrap">
{row.id ? (
<>
<button className="btn btn-primary btn-sm" type="button" onClick={() => navigate(`/app/administrator/calendar_edit/${row.id}`)}>
Edit
</button>
<button className="btn btn-danger btn-sm" type="button" onClick={() => onDelete(row.id as number)}>
Delete
</button>
</>
) : (
<span className="text-muted small">Meeting event</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
export function AdminCalendarPage() {
const [events, setEvents] = useState<SchoolCalendarEventRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchAdministratorCalendarEvents()
if (cancelled) return
setEvents(data.data?.events ?? [])
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load calendar.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const grouped = useMemo(() => {
const map = new Map<string, SchoolCalendarEventRow[]>()
for (const row of events) {
const key = formatDateInput(row.start).slice(0, 7)
if (!map.has(key)) map.set(key, [])
map.get(key)?.push(row)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, rows]) => ({ month, rows }))
}, [events])
return (
<AdminParityShell title="School calendar">
<div className="d-flex justify-content-end mb-3">
<Link to="/app/administrator/events" className="btn btn-outline-secondary">
Manage events (list)
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? (
<p className="text-muted">Loading calendar</p>
) : grouped.length === 0 ? (
<p className="text-muted">No calendar events found.</p>
) : (
<div className="row g-3">
{grouped.map((group) => (
<div key={group.month} className="col-12 col-xl-6">
<div className="card h-100">
<div className="card-header">{monthLabel(`${group.month}-01`)}</div>
<div className="list-group list-group-flush">
{group.rows.map((row) => (
<div key={String(row.id ?? row.start + row.title)} className="list-group-item">
<div className="d-flex justify-content-between gap-3">
<div>
<div className="fw-semibold">{row.title}</div>
<div className="small text-muted">{formatDate(row.start)}</div>
{row.description ? <div className="small mt-1">{row.description}</div> : null}
</div>
<div className="text-end small text-muted">
<div>{String(row.extendedProps?.event_type ?? 'General')}</div>
<div>{eventAudienceBadges(row).join(', ') || '—'}</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
</AdminParityShell>
)
}
export function AdminClassAssignmentPage() {
type ClassAssignmentStudent = {
id?: number
firstname?: string | null
lastname?: string | null
age?: number | string | null
gender?: string | null
photo_consent?: boolean | number | null
tuition_paid?: boolean | number | null
school_id?: string | null
}
type ClassAssignmentSortKey = 'school_id' | 'firstname' | 'lastname' | 'age' | 'gender' | 'photo_consent'
const [sections, setSections] = useState<
Array<{
class_section_id?: number | null
class_section_name?: string | null
main_teachers?: string[]
mainTeachers?: string[]
teacher_assistants?: string[]
teacherAssistants?: string[]
students?: ClassAssignmentStudent[]
semester?: string | null
school_year?: string | null
description?: string | null
}>
>([])
const [schoolYears, setSchoolYears] = useState<string[]>([])
const [selectedYear, setSelectedYear] = useState('')
const [loading, setLoading] = useState(true)
const [message] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedSemester, setSelectedSemester] = useState('')
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['0']))
const [sortConfig, setSortConfig] = useState<{ key: ClassAssignmentSortKey; direction: 'asc' | 'desc' }>({
key: 'school_id',
direction: 'asc',
})
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: selectedYear,
})
async function load(year?: string | null) {
setLoading(true)
try {
const response = await fetchClassAssignmentOverview(year ?? null, null)
const data = response.data ?? {}
const classSections = data.classSections ?? []
setSections(classSections)
setSchoolYears(data.schoolYears ?? [])
setSelectedYear(data.selectedYear ?? data.schoolYear ?? defaultYear)
setSelectedSemester(data.selectedSemester ?? data.semester ?? '')
setExpandedSections(new Set(classSections.length > 0 ? ['0'] : []))
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load class assignments.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load(null)
}, [])
function toggleSection(key: string) {
setExpandedSections((prev) => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
return next
})
}
function requestSort(key: ClassAssignmentSortKey) {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'asc' },
)
}
function sortIndicator(key: ClassAssignmentSortKey) {
if (sortConfig.key !== key) return '↕'
return sortConfig.direction === 'asc' ? '↑' : '↓'
}
function sortedStudents(students: ClassAssignmentStudent[]) {
return [...students].sort((left, right) => {
const dir = sortConfig.direction === 'asc' ? 1 : -1
const leftBool = Boolean(left.photo_consent)
const rightBool = Boolean(right.photo_consent)
switch (sortConfig.key) {
case 'age': {
const a = Number(left.age ?? 0)
const b = Number(right.age ?? 0)
return (a - b) * dir
}
case 'photo_consent':
return (Number(leftBool) - Number(rightBool)) * dir
case 'school_id':
case 'firstname':
case 'lastname':
case 'gender': {
const a = String(left[sortConfig.key] ?? '').toLowerCase()
const b = String(right[sortConfig.key] ?? '').toLowerCase()
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * dir
}
}
})
}
return (
<AdminParityShell title="Classes Lists">
{message ? <div className="alert alert-success">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<form
className="row g-2 align-items-center justify-content-center mb-3"
onSubmit={(event) => {
event.preventDefault()
void load(selectedYear)
}}
>
<div className="col-auto">
<label className="form-label mb-0">School year</label>
</div>
<div className="col-auto">
<select
className="form-select form-select-sm"
style={{ minWidth: 200 }}
value={selectedYear || defaultYear}
onChange={(event) => setSelectedYear(event.target.value)}
>
{options.map((year) => (
<option key={year.value} value={year.value}>{year.label}</option>
))}
</select>
</div>
<div className="col-auto d-flex gap-2">
<button type="submit" className="btn btn-secondary btn-sm">Apply</button>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => void load(null)}>Reset</button>
</div>
</form>
{loading ? (
<p className="text-muted px-1">Loading class sections</p>
) : sections.length === 0 ? (
<p className="text-muted px-1">No class sections available.</p>
) : (
<div id="classAccordion" className="d-flex flex-column gap-2">
{sections.map((section, index) => {
const key = String(index)
const expanded = expandedSections.has(key)
const students = section.students ?? []
const displayedStudents = sortedStudents(students)
const mainTeachers =
section.main_teachers ?? section.mainTeachers ?? []
const teacherAssistants =
section.teacher_assistants ?? section.teacherAssistants ?? []
return (
<div key={key} className="card border-0 shadow-sm rounded-3">
<div
className="card-header bg-white d-flex align-items-center justify-content-between rounded-3"
role="button"
style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggleSection(key)}
>
<div className="d-flex flex-column">
<span className="fw-semibold">
{expanded ? '▼' : '▶'}&nbsp;&nbsp;Grade : {section.class_section_name ?? 'Class'}
</span>
<span className="small text-muted">
Teacher: {mainTeachers.length > 0 ? mainTeachers.join(', ') : 'None'}
{' '}| TA: {teacherAssistants.length > 0 ? teacherAssistants.join(', ') : 'None'}
</span>
</div>
<span className="badge bg-secondary rounded-pill">{students.length}</span>
</div>
{expanded && (
<div className="card-body">
<h5>
Main Teacher:{' '}
{mainTeachers.length > 0
? mainTeachers.join(', ')
: 'None'}
</h5>
<h5>
Teacher-Assistant:{' '}
{teacherAssistants.length > 0
? teacherAssistants.join(', ')
: 'None'}
</h5>
<h5>Semester: {selectedSemester || 'N/A'}</h5>
<h5>School Year: {section.school_year ?? selectedYear ?? 'N/A'}</h5>
<p>{section.description ?? ''}</p>
{students.length > 0 ? (
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead className="table-light">
<tr>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('school_id')}>
School ID {sortIndicator('school_id')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('firstname')}>
First Name {sortIndicator('firstname')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('lastname')}>
Last Name {sortIndicator('lastname')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('age')}>
Age {sortIndicator('age')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('gender')}>
Gender {sortIndicator('gender')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('photo_consent')}>
Photo Consent {sortIndicator('photo_consent')}
</button>
</th>
</tr>
</thead>
<tbody>
{displayedStudents.map((student, studentIndex) => (
<tr key={`${section.class_section_id ?? index}-${student.id ?? studentIndex}`}>
<td>{student.school_id ?? '—'}</td>
<td>{student.firstname ?? '—'}</td>
<td>{student.lastname ?? '—'}</td>
<td>{student.age ?? '—'}</td>
<td>{student.gender ?? '—'}</td>
<td>
<span className={`badge ${student.photo_consent ? 'bg-success' : 'bg-danger'}`}>
{student.photo_consent ? 'Yes' : 'No'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p>No students registered in this class section.</p>
)}
</div>
)}
</div>
)
})}
</div>
)}
</AdminParityShell>
)
}
export function AdminClassSectionPage() {
const [sections, setSections] = useState<ClassSectionRow[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [form, setForm] = useState({
class_id: '',
class_section_id: '',
class_section_name: '',
semester: '',
school_year: '',
})
async function load(searchValue = '') {
setLoading(true)
try {
const data = await fetchClassSections({ search: searchValue || undefined, perPage: 100 })
setSections(data.data?.sections ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load class sections.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load('')
}, [])
function startEdit(row: ClassSectionRow) {
setEditingId(row.id)
setForm({
class_id: String(row.class_id ?? ''),
class_section_id: String(row.class_section_id ?? ''),
class_section_name: row.class_section_name ?? '',
semester: row.semester ?? '',
school_year: row.school_year ?? '',
})
}
function resetForm() {
setEditingId(null)
setForm({
class_id: '',
class_section_id: '',
class_section_name: '',
semester: '',
school_year: '',
})
}
async function submit(event: FormEvent) {
event.preventDefault()
setSaving(true)
setMessage(null)
setError(null)
try {
const payload = {
class_id: Number(form.class_id),
class_section_id: Number(form.class_section_id),
class_section_name: form.class_section_name,
semester: form.semester,
school_year: form.school_year,
}
if (editingId) {
await updateClassSection(editingId, payload)
setMessage('Class section updated.')
} else {
await createClassSection(payload)
setMessage('Class section created.')
}
resetForm()
await load(search)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to save class section.')
} finally {
setSaving(false)
}
}
async function remove(sectionId: number) {
if (!window.confirm('Delete this class section?')) return
await deleteClassSection(sectionId)
await load(search)
}
return (
<AdminParityShell title="Classes">
{message ? <div className="alert alert-success">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card mb-4">
<div className="card-header">{editingId ? 'Edit Class' : 'Add New Class'}</div>
<div className="card-body">
<form className="row g-3" onSubmit={submit}>
<div className="col-md-3">
<label className="form-label">Class ID</label>
<input className="form-control" value={form.class_id} onChange={(event) => setForm((current) => ({ ...current, class_id: event.target.value }))} required />
</div>
<div className="col-md-3">
<label className="form-label">Section ID</label>
<input className="form-control" value={form.class_section_id} onChange={(event) => setForm((current) => ({ ...current, class_section_id: event.target.value }))} required />
</div>
<div className="col-md-3">
<label className="form-label">Name</label>
<input className="form-control" value={form.class_section_name} onChange={(event) => setForm((current) => ({ ...current, class_section_name: event.target.value }))} required />
</div>
<div className="col-md-1">
<label className="form-label">Semester</label>
<input className="form-control" value={form.semester} onChange={(event) => setForm((current) => ({ ...current, semester: event.target.value }))} required />
</div>
<div className="col-md-2">
<label className="form-label">School Year</label>
<input className="form-control" value={form.school_year} onChange={(event) => setForm((current) => ({ ...current, school_year: event.target.value }))} required />
</div>
<div className="col-12 d-flex gap-2 justify-content-end">
{editingId ? <button type="button" className="btn btn-secondary" onClick={resetForm}>Cancel</button> : null}
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : editingId ? 'Update Class' : 'Create Class'}
</button>
</div>
</form>
</div>
</div>
<div className="d-flex justify-content-end mb-3">
<div className="input-group" style={{ maxWidth: 340 }}>
<input className="form-control" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search class sections" />
<button className="btn btn-outline-secondary" type="button" onClick={() => void load(search)}>
Search
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th>ID</th>
<th>Class ID</th>
<th>Section ID</th>
<th>Name</th>
<th>Semester</th>
<th>School Year</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-muted">Loading classes</td></tr>
) : sections.length === 0 ? (
<tr><td colSpan={7} className="text-muted">No class sections found.</td></tr>
) : (
sections.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.class_id ?? '—'}</td>
<td>{row.class_section_id ?? '—'}</td>
<td>{row.class_section_name ?? '—'}</td>
<td>{row.semester ?? '—'}</td>
<td>{row.school_year ?? '—'}</td>
<td className="d-flex gap-2">
<button className="btn btn-warning btn-sm" type="button" onClick={() => startEdit(row)}>
Edit
</button>
<button className="btn btn-danger btn-sm" type="button" onClick={() => void remove(row.id)}>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
export function AdminDailyAttendancePage() {
const [searchParams, setSearchParams] = useSearchParams()
const semesterParam = searchParams.get('semester') ?? ''
const schoolYearParam = searchParams.get('school_year') ?? ''
const [data, setData] = useState<Awaited<ReturnType<typeof fetchAdministratorDailyAttendance>> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [studentSearch, setStudentSearch] = useState('')
const [tableSearch, setTableSearch] = useState('')
const [activeGradeKey, setActiveGradeKey] = useState<string>('')
const [activeSectionId, setActiveSectionId] = useState<string>('')
const [termFilters, setTermFilters] = useState(() => ({
semester: semesterParam.trim() === 'Spring' ? 'Spring' : 'Fall',
school_year: schoolYearParam,
}))
const [pageSize, setPageSize] = useState(100)
const [datePageIndex, setDatePageIndex] = useState(0)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [actionMessage, setActionMessage] = useState<string | null>(null)
useEffect(() => {
if (!actionMessage) return
const t = window.setTimeout(() => setActionMessage(null), 2500)
return () => window.clearTimeout(t)
}, [actionMessage])
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const result = await fetchAdministratorDailyAttendance({
semester: semesterParam || null,
schoolYear: schoolYearParam || null,
})
if (cancelled) return
setData(result)
const apiSchoolYear = result.school_year != null ? String(result.school_year).trim() : ''
const apiSemester = result.semester != null ? String(result.semester).trim() : ''
setTermFilters((current) => ({
school_year: schoolYearParam.trim() || current.school_year.trim() || apiSchoolYear,
semester:
semesterParam.trim() === 'Spring'
? 'Spring'
: semesterParam.trim() === 'Fall'
? 'Fall'
: apiSemester === 'Spring' || apiSemester === 'Fall'
? apiSemester
: current.semester === 'Spring'
? 'Spring'
: 'Fall',
}))
const sections = buildAttendanceSections(result)
const groups = buildAttendanceGradeGroups(sections)
setActiveGradeKey(groups[0]?.key ?? '')
setActiveSectionId(sections[0]?.sectionId ?? '')
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load attendance.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [schoolYearParam, semesterParam])
useEffect(() => {
setTermFilters((current) => ({
school_year: schoolYearParam.trim() || current.school_year,
semester:
semesterParam.trim() === 'Spring'
? 'Spring'
: semesterParam.trim() === 'Fall'
? 'Fall'
: current.semester === 'Spring'
? 'Spring'
: 'Fall',
}))
}, [schoolYearParam, semesterParam])
const sections = useMemo(() => (data ? buildAttendanceSections(data) : []), [data])
const gradeGroups = useMemo(() => buildAttendanceGradeGroups(sections), [sections])
const normalizedSearch = normalizeAttendanceSearch(studentSearch)
const searchMatches = useMemo(() => {
if (normalizedSearch === '') return []
return sections.flatMap((section) =>
section.students
.filter((student) => {
return attendanceSearchMatchesPrefix(normalizedSearch, [
student.schoolId,
student.name,
`${student.schoolId} ${student.name}`,
`${student.name} ${student.schoolId}`,
`${student.name} ${section.className}`,
])
})
.map((student) => ({
section,
student,
})),
)
}, [normalizedSearch, sections])
const selectedSearchMatch = searchMatches[0] ?? null
const displayedGrade = gradeGroups.find((group) => group.key === activeGradeKey) ?? gradeGroups[0]
const gradeSections = displayedGrade?.sections ?? []
const displayedSection =
gradeSections.find((row) => row.sectionId === activeSectionId) ??
gradeSections[0] ??
sections.find((row) => row.sectionId === activeSectionId) ??
sections[0]
const sectionFilter = normalizeAttendanceSearch(tableSearch)
const renderedStudents = useMemo<AttendanceRenderedStudentRow[]>(() => {
if (normalizedSearch !== '') {
const seen = new Set<string>()
return searchMatches.flatMap((match) => {
const key = `${match.section.sectionId}:${match.student.id}`
if (seen.has(key)) return []
seen.add(key)
return [{
sectionId: match.section.sectionId,
sectionName: match.section.className,
student: match.student,
}]
})
}
if (!displayedSection) return []
return displayedSection.students
.filter((student) => {
if (sectionFilter === '') return true
return attendanceSearchMatchesPrefix(sectionFilter, [
student.schoolId,
student.name,
`${student.schoolId} ${student.name}`,
`${student.name} ${student.schoolId}`,
`${student.name} ${displayedSection.className}`,
])
})
.map((student) => ({
sectionId: displayedSection.sectionId,
sectionName: displayedSection.className,
student,
}))
}, [displayedSection, normalizedSearch, searchMatches, sectionFilter])
const allVisibleDates = useMemo(() => {
if (normalizedSearch !== '') {
const set = new Set<string>()
for (const match of searchMatches) {
for (const date of match.section.dates) set.add(date)
}
return Array.from(set).sort()
}
return displayedSection?.dates ?? []
}, [displayedSection, normalizedSearch, searchMatches])
const allDates = useMemo(() => {
const recorded = new Set(allVisibleDates)
const semesterDates = Array.isArray(data?.date_list) ? data.date_list as string[] : []
let coming: string[]
if (semesterDates.length > 0) {
// Show all semester Sundays not yet recorded, including no-school days (they'll be flagged visually)
coming = semesterDates.filter((d) => !recorded.has(d))
} else {
const today = new Date().toISOString().slice(0, 10)
const anchor = allVisibleDates.length > 0
? allVisibleDates[allVisibleDates.length - 1]
: today
coming = []
let cur = nextSundayAfter(anchor)
while (cur && coming.length < 20) {
if (!recorded.has(cur)) coming.push(cur)
cur = nextSundayAfter(cur)
}
}
return [...allVisibleDates, ...coming].sort()
}, [allVisibleDates, data?.date_list])
const noSchoolDays: Record<string, string> = data?.no_school_days ?? {}
const DATE_PAGE_SIZE = 5
const datePageCount = Math.max(1, Math.ceil(allDates.length / DATE_PAGE_SIZE))
const clampedDatePage = Math.min(datePageIndex, datePageCount - 1)
const visibleDates = allDates.slice(
clampedDatePage * DATE_PAGE_SIZE,
clampedDatePage * DATE_PAGE_SIZE + DATE_PAGE_SIZE,
)
const visibleStudents = useMemo(() => {
return renderedStudents.filter(({ student, sectionName }) => {
if (sectionFilter === '') return true
return attendanceSearchMatchesPrefix(sectionFilter, [
student.schoolId,
student.name,
`${student.schoolId} ${student.name}`,
`${student.name} ${student.schoolId}`,
`${student.name} ${sectionName}`,
])
})
}, [renderedStudents, sectionFilter])
const tableRows = useMemo(() => visibleStudents.slice(0, pageSize), [visibleStudents, pageSize])
const tableEmptyColSpan = 1 + (normalizedSearch !== '' ? 1 : 0) + 1 + visibleDates.length + 4
const schoolYearOptions = useMemo(() => {
const set = new Set<string>()
for (const y of [termFilters.school_year, data?.school_year != null ? String(data.school_year) : '']) {
const t = String(y).trim()
if (t) set.add(t)
}
return Array.from(set).sort()
}, [termFilters.school_year, data?.school_year])
useEffect(() => {
if (!displayedGrade) return
if (!gradeSections.some((section) => section.sectionId === activeSectionId)) {
setActiveSectionId(displayedGrade.sections[0]?.sectionId ?? '')
}
}, [activeSectionId, displayedGrade, gradeSections])
useEffect(() => {
setDatePageIndex(Number.MAX_SAFE_INTEGER)
}, [activeSectionId, activeGradeKey, normalizedSearch])
async function persistAttendanceCell(
section: AttendanceSectionSnapshot,
student: AttendanceSectionSnapshot['students'][number],
date: string,
raw: string,
) {
const cellKey = `${section.sectionId}-${student.id}-${date}`
if (raw === '') return
let status: 'present' | 'absent' | 'late'
let is_reported: string | null = null
if (raw === 'present') status = 'present'
else if (raw === 'absent') status = 'absent'
else if (raw === 'absent_reported') {
status = 'absent'
is_reported = 'yes'
} else if (raw === 'late_reported') {
status = 'late'
is_reported = 'yes'
} else if (raw === 'late') {
status = 'late'
is_reported = 'no'
} else {
return
}
const semesterVal = String(data?.semester ?? semesterParam ?? '').trim()
const schoolYearVal = String(data?.school_year ?? schoolYearParam ?? '').trim()
// Optimistic update — patch local data immediately so the UI reflects the change at once
const prevData = data
setData((current) => {
if (!current) return current
const secId = String(section.sectionId)
const stuId = String(student.id)
const prevEntries = JSON.parse(
JSON.stringify(current.attendance_data ?? {}),
) as NonNullable<typeof current.attendance_data>
if (!prevEntries[secId]) prevEntries[secId] = {}
const existing = (prevEntries[secId][stuId] ?? []).filter((e) => String(e.date ?? '').slice(0, 10) !== date)
prevEntries[secId][stuId] = [
...existing,
{ date, status, reason: null, is_reported: is_reported ?? 'no' },
]
return { ...current, attendance_data: prevEntries }
})
setBusyKey(cellKey)
setError(null)
try {
await updateAdministratorAttendance({
class_section_id: Number(section.sectionId),
student_id: student.id,
school_id: student.schoolId && student.schoolId !== '—' ? student.schoolId : null,
status,
date,
class_id: section.classId,
semester: semesterVal || null,
school_year: schoolYearVal || null,
is_reported: (status === 'late' || status === 'absent') ? is_reported : null,
})
setActionMessage('Saved.')
} catch (e) {
setData(prevData)
setError(e instanceof Error ? e.message : 'Unable to save attendance.')
} finally {
setBusyKey(null)
}
}
return (
<AdminParityShell title="Attendance Management" showTitle={false}>
<div className="att-mgmt-page">
<div className="text-center mb-4">
<h1 className="att-mgmt-hero-title mb-0">Attendance Management</h1>
</div>
<form
className="att-mgmt-filter-bar row g-3 justify-content-center align-items-end mb-4"
onSubmit={(event) => {
event.preventDefault()
const next = new URLSearchParams()
if (termFilters.school_year.trim() !== '') next.set('school_year', termFilters.school_year.trim())
if (termFilters.semester.trim() !== '') next.set('semester', termFilters.semester.trim())
setSearchParams(next)
}}
>
<div className="col-12 col-sm-auto">
<label className="form-label">School year</label>
<select
className="form-select"
value={termFilters.school_year}
onChange={(event) => setTermFilters((current) => ({ ...current, school_year: event.target.value }))}
>
{schoolYearOptions.length === 0 ? (
<option value="">{loading ? 'Loading…' : '—'}</option>
) : (
schoolYearOptions.map((year) => (
<option key={year} value={year}>
{year}
</option>
))
)}
</select>
</div>
<div className="col-12 col-sm-auto">
<label className="form-label">Semester</label>
<select
className="form-select"
value={termFilters.semester === 'Spring' ? 'Spring' : 'Fall'}
onChange={(event) => setTermFilters((current) => ({ ...current, semester: event.target.value }))}
>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="col-12 col-sm-auto d-flex flex-wrap gap-2">
<button className="btn att-btn-apply" type="submit">
Apply
</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => {
setTermFilters({ semester: 'Fall', school_year: '' })
setSearchParams(new URLSearchParams())
}}
>
Reset
</button>
</div>
</form>
<div className="row g-2 justify-content-center mb-4">
<div className="col-12 col-lg-8">
<div className="input-group att-search-wrap">
<span className="input-group-text">Search Student</span>
<input
className="form-control"
type="text"
placeholder="Type School ID or Student Name"
value={studentSearch}
onChange={(event) => setStudentSearch(event.target.value)}
/>
<button className="btn btn-outline-secondary" type="button" onClick={() => setStudentSearch('')}>
Clear
</button>
</div>
<small className="text-muted">Searches all grades.</small>
{normalizedSearch !== '' ? (
<div className={`small mt-1 ${searchMatches.length > 0 ? 'text-success' : 'text-danger'}`}>
{searchMatches.length > 0
? `${searchMatches.length} match${searchMatches.length === 1 ? '' : 'es'} found.${
selectedSearchMatch ? ` First match: ${selectedSearchMatch.section.className}.` : ''
}`
: 'No student matched the current search.'}
</div>
) : null}
{normalizedSearch !== '' && searchMatches.length > 0 ? (
<div className="list-group mt-2" style={{ maxHeight: 240, overflowY: 'auto' }}>
{searchMatches.slice(0, 12).map((match) => (
<button
key={`${match.section.sectionId}-${match.student.id}`}
type="button"
className="list-group-item list-group-item-action"
onClick={() => {
setActiveGradeKey(
gradeGroups.find((group) =>
group.sections.some((section) => section.sectionId === match.section.sectionId),
)?.key ?? '',
)
setActiveSectionId(match.section.sectionId)
}}
>
<div className="fw-semibold">
{match.student.schoolId} - {match.student.name}
</div>
<div className="small text-muted">{match.section.className}</div>
</button>
))}
</div>
) : null}
</div>
</div>
<div className="text-center mb-4">
<Link className="btn btn-outline-primary att-btn-analysis" to="/app/administrator/daily_attendance_analysis">
Attendance Analysis
</Link>
</div>
{actionMessage ? <div className="alert alert-success py-2 text-center">{actionMessage}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? (
<p className="text-muted text-center">Loading attendance</p>
) : sections.length === 0 ? (
<p className="text-muted text-center">No attendance sections found.</p>
) : (
<>
<div className="att-grade-tabs mb-3" role="tablist" aria-label="Grade">
{gradeGroups.map((group) => {
const count = group.sections.reduce((acc, section) => acc + section.students.length, 0)
const active = group.key === displayedGrade?.key
return (
<button
key={group.key}
type="button"
role="tab"
aria-selected={active}
className={`att-grade-tab ${active ? 'active' : ''}`}
onClick={() => setActiveGradeKey(group.key)}
>
<span className="att-dot" aria-hidden="true" />
<span>{group.label}</span>
<span className="att-count">{count}</span>
</button>
)
})}
</div>
{gradeSections.length > 1 ? (
<div className="att-section-pills mb-3">
{gradeSections.map((section) => (
<button
key={section.sectionId}
type="button"
className={`btn btn-sm ${section.sectionId === displayedSection?.sectionId ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setActiveSectionId(section.sectionId)}
>
{section.className}
</button>
))}
</div>
) : null}
{displayedSection ? (
<>
<div className="text-center mb-3">
<h2 className="att-section-heading mb-3">
{normalizedSearch !== '' ? 'Search Results' : displayedSection.className}
</h2>
<div className="d-flex justify-content-center align-items-center gap-3 att-date-range">
<button
type="button"
className="btn btn-primary att-date-nav"
disabled={clampedDatePage === 0}
onClick={() => setDatePageIndex(clampedDatePage - 1)}
>
</button>
<span>
{visibleDates[0] ? formatAttendanceMdY(visibleDates[0]) : '—'}
{visibleDates.length > 1
? `${formatAttendanceMdY(visibleDates[visibleDates.length - 1])}`
: ''}
</span>
<button
type="button"
className="btn btn-primary att-date-nav"
disabled={clampedDatePage >= datePageCount - 1}
onClick={() => setDatePageIndex(clampedDatePage + 1)}
>
</button>
</div>
</div>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-2 att-table-toolbar">
<div className="d-flex align-items-center gap-2">
<span>Show</span>
<select
className="form-select form-select-sm"
style={{ width: 78 }}
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
>
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<span>entries</span>
</div>
<div className="d-flex align-items-center gap-2">
<label className="mb-0">Search:</label>
<input
className="form-control form-control-sm"
style={{ width: 220 }}
value={tableSearch}
onChange={(event) => setTableSearch(event.target.value)}
/>
</div>
</div>
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle att-mgmt-table">
<thead className="att-mgmt-thead">
<tr>
<th className="school-id-col">School ID</th>
{normalizedSearch !== '' ? <th>Section</th> : null}
<th className="student-name-col">Student Name</th>
{visibleDates.map((date) => {
const noSchoolTitle = noSchoolDays[date]
return (
<th
key={date}
className={`date-col${noSchoolTitle ? ' table-info' : ''}`}
title={noSchoolTitle ?? undefined}
>
{formatAttendanceMdY(date)}
{noSchoolTitle ? <div style={{ fontSize: '0.7rem', fontWeight: 400 }}>{noSchoolTitle}</div> : null}
</th>
)
})}
<th className="text-center">P</th>
<th className="text-center">L</th>
<th className="text-center">A</th>
<th className="text-center">T</th>
</tr>
</thead>
<tbody>
{tableRows.map(({ sectionId, sectionName, student }) => {
const sectionSnap = sections.find((s) => s.sectionId === sectionId)
if (!sectionSnap) return null
return (
<tr key={`${sectionId}-${student.id}`}>
<td className="school-id-col">{student.schoolId}</td>
{normalizedSearch !== '' ? <td>{sectionName}</td> : null}
<td className="student-name-col">{student.name}</td>
{visibleDates.map((date) => {
const noSchoolTitle = noSchoolDays[date]
const entry = student.entriesByDate[date]
const cellVal = entryToAttendanceSelectValue(entry)
const cellBusy = busyKey === `${sectionId}-${student.id}-${date}`
return (
<td key={date} className={`date-col${noSchoolTitle ? ' table-info' : ''}`}>
<select
className={attendanceCellSelectClass(cellVal)}
value={cellVal}
disabled={cellBusy || loading || !!noSchoolTitle}
onChange={(event) => {
void persistAttendanceCell(sectionSnap, student, date, event.target.value)
}}
>
<option value="">Select attendance</option>
<option value="present">Present</option>
<option value="late">Late</option>
<option value="late_reported">Late Reported</option>
<option value="absent">Absent</option>
<option value="absent_reported">Absent Reported</option>
</select>
</td>
)
})}
<td className="att-summary-col">{student.summary.present}</td>
<td className="att-summary-col">{student.summary.late}</td>
<td className="att-summary-col">{student.summary.absent}</td>
<td className="att-summary-col">{student.summary.total}</td>
</tr>
)
})}
{tableRows.length === 0 ? (
<tr>
<td colSpan={tableEmptyColSpan} className="text-muted text-center">
No students match the current search.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</>
) : null}
</>
)}
</div>
</AdminParityShell>
)
}
export function AdminDailyAttendanceAnalysisPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof fetchAdministratorDailyAttendance>> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const result = await fetchAdministratorDailyAttendance()
if (cancelled) return
setData(result)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load attendance analysis.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const sections = useMemo(() => (data ? buildAttendanceSections(data) : []), [data])
const analysis = useMemo(() => buildAttendanceAnalysis(sections), [sections])
const total = analysis.overall.present + analysis.overall.late + analysis.overall.absent
return (
<AdminParityShell title="Attendance Analysis">
<div className="d-flex justify-content-end mb-3">
<Link className="btn btn-outline-secondary" to="/app/administrator/daily_attendance">
Back to Attendance
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? (
<p className="text-muted">Loading analysis</p>
) : (
<div className="row g-3">
<div className="col-md-4">
<div className="card h-100">
<div className="card-body">
<h5 className="card-title">Overall</h5>
<p className="mb-2">Present: {analysis.overall.present}</p>
<p className="mb-2">Late: {analysis.overall.late}</p>
<p className="mb-0">Absent: {analysis.overall.absent}</p>
<hr />
<p className="mb-0 small text-muted">Total attendance marks: {total}</p>
</div>
</div>
</div>
<div className="col-md-8">
<div className="card h-100">
<div className="card-body">
<h5 className="card-title">By Class</h5>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Class</th>
<th>Students</th>
<th>Present</th>
<th>Late</th>
<th>Absent</th>
</tr>
</thead>
<tbody>
{analysis.byClass.map(([className, row]) => (
<tr key={className}>
<td>{className}</td>
<td>{row.totalStudents}</td>
<td>{row.present}</td>
<td>{row.late}</td>
<td>{row.absent}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div className="col-12">
<div className="card">
<div className="card-body">
<h5 className="card-title">Students Needing Attention</h5>
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th>Student</th>
<th>School ID</th>
<th>Class</th>
<th>Absent</th>
<th>Late</th>
</tr>
</thead>
<tbody>
{analysis.studentsNeedingAttention.length === 0 ? (
<tr><td colSpan={5} className="text-muted">No absences or tardies recorded.</td></tr>
) : (
analysis.studentsNeedingAttention.slice(0, 50).map((student) => (
<tr key={`${student.schoolId}-${student.name}`}>
<td>{student.name}</td>
<td>{student.schoolId}</td>
<td>{student.className}</td>
<td>{student.absent}</td>
<td>{student.late}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
)}
</AdminParityShell>
)
}
export function AdminExamDraftsPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof fetchExamDraftAdminData>> | null>(null)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'submissions' | 'legacy'>('submissions')
const [legacyFile, setLegacyFile] = useState<File | null>(null)
const [legacyForm, setLegacyForm] = useState({
class_section_id: '',
school_year: '',
semester: '',
exam_type: '',
})
const [reviewState, setReviewState] = useState<Record<number, { review_status: string; admin_comments: string; final_file: File | null }>>({})
async function load() {
try {
const result = await fetchExamDraftAdminData()
setData(result)
setLegacyForm((current) => ({
...current,
school_year: current.school_year || result.school_year || '',
semester: current.semester || result.semester || '',
}))
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load exam drafts.')
}
}
useEffect(() => {
void load()
}, [])
async function submitLegacy(event: FormEvent) {
event.preventDefault()
if (!legacyForm.class_section_id) {
setError('Select a class section before uploading a legacy exam.')
return
}
if (!legacyFile) {
setError('Choose a file to upload.')
return
}
try {
setError(null)
const result = await uploadLegacyExamDraft({
class_section_id: Number(legacyForm.class_section_id),
school_year: legacyForm.school_year,
semester: legacyForm.semester,
exam_type: legacyForm.exam_type || undefined,
old_exam_file: legacyFile,
})
setMessage(result.message ?? 'Legacy exam uploaded.')
setLegacyFile(null)
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to upload legacy exam.')
}
}
async function submitReview(draft: ExamDraftRow) {
const row = reviewState[draft.id] ?? { review_status: '', admin_comments: '', final_file: null }
try {
const result = await reviewExamDraft({
draft_id: draft.id,
review_status: row.review_status || undefined,
admin_comments: row.admin_comments || undefined,
final_file: row.final_file,
})
setMessage(result.message ?? 'Draft review updated.')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to review draft.')
}
}
const teacherFilePath = (name?: string | null) => (name ? `/api/v1/files/exams/teacher/${encodeURIComponent(name)}` : null)
const finalFilePath = (name?: string | null) => (name ? `/api/v1/files/exams/final/${encodeURIComponent(name)}` : null)
const allowedExtensions = data?.allowed_extensions ?? ['doc', 'docx', 'pdf']
const maxUploadMb = Math.max(1, Math.round((data?.max_upload_bytes ?? 12 * 1024 * 1024) / 1024 / 1024))
const classSections = data?.class_sections ?? []
const drafts = (data?.drafts ?? []).filter((draft) => !Boolean(draft.is_legacy))
const legacyByClass = data?.legacy_by_class ?? {}
const draftsByClass = useMemo(() => {
const grouped = new Map<number, ExamDraftRow[]>()
for (const draft of drafts) {
const sectionId = Number(draft.class_section_id ?? 0)
if (!sectionId) continue
const rows = grouped.get(sectionId) ?? []
rows.push(draft)
grouped.set(sectionId, rows)
}
for (const rows of grouped.values()) {
rows.sort((a, b) => {
const aTime = String(a.reviewed_at ?? '')
const bTime = String(b.reviewed_at ?? '')
return bTime.localeCompare(aTime)
})
}
return grouped
}, [drafts])
const visibleClasses = useMemo(() => {
const base = classSections
.map((section) => ({
class_section_id: Number(section.class_section_id ?? 0),
class_section_name: section.class_section_name ?? '',
}))
.filter((section) => section.class_section_id > 0)
const seen = new Set<number>()
const merged: Array<{ class_section_id: number; class_section_name: string }> = []
for (const section of base) {
if (seen.has(section.class_section_id)) continue
seen.add(section.class_section_id)
merged.push(section)
}
for (const draft of drafts) {
const sectionId = Number(draft.class_section_id ?? 0)
if (!sectionId || seen.has(sectionId)) continue
seen.add(sectionId)
merged.push({
class_section_id: sectionId,
class_section_name: draft.class_section_name ?? `Class ${sectionId}`,
})
}
return merged.sort((a, b) => a.class_section_name.localeCompare(b.class_section_name))
}, [classSections, drafts])
function statusBadgeClass(status?: string | null): string {
switch (String(status ?? '').toLowerCase()) {
case 'submitted':
return 'bg-warning text-dark'
case 'reviewed':
return 'bg-info text-dark'
case 'finalized':
return 'bg-success'
case 'rejected':
return 'bg-danger'
default:
return 'bg-secondary'
}
}
return (
<AdminParityShell title="Exam Draft Submissions">
{message ? <div className="alert alert-success">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div>
<p className="text-muted mb-0 small">{data?.semester || 'Semester'} {data?.school_year || ''}</p>
</div>
<div className="text-muted small d-flex flex-column align-items-lg-end">
<span>Allowed files: {allowedExtensions.join(', ')}</span>
<span>Max size: {maxUploadMb} MB</span>
</div>
</div>
<div className="card mb-3">
<div className="card-body py-3 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
<div>
<div className="fw-semibold">View</div>
<div className="small text-muted">Switch between new draft submissions and legacy uploaded exams.</div>
</div>
<div className="btn-group" role="group" aria-label="Exam draft tabs">
<button
type="button"
className={`btn ${activeTab === 'submissions' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('submissions')}
aria-pressed={activeTab === 'submissions'}
>
Draft submissions
</button>
<button
type="button"
className={`btn ${activeTab === 'legacy' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('legacy')}
aria-pressed={activeTab === 'legacy'}
>
Legacy exams
</button>
</div>
</div>
</div>
{activeTab === 'submissions' ? (
<div className="card mb-4">
<div className="card-body">
{visibleClasses.length === 0 ? (
<p className="text-muted mb-0">No classes with enrolled students are available for this term.</p>
) : (
<div className="accordion" id="examDraftsAccordion">
{visibleClasses.map((section, index) => {
const sectionDrafts = draftsByClass.get(section.class_section_id) ?? []
const submittedCount = sectionDrafts.filter((draft) => String(draft.status ?? '').toLowerCase() === 'submitted').length
const collapseId = `examDraftGroup_${section.class_section_id}`
return (
<div className="accordion-item mb-3" key={section.class_section_id}>
<h2 className="accordion-header" id={`heading_${collapseId}`}>
<button
className={`accordion-button px-3 ${index === 0 ? '' : 'collapsed'}`}
type="button"
data-bs-toggle="collapse"
data-bs-target={`#${collapseId}`}
aria-expanded={index === 0}
aria-controls={collapseId}
>
<div className="d-flex flex-column flex-lg-row w-100 justify-content-between gap-2">
<div>
<strong>{section.class_section_name || `Class ${section.class_section_id}`}</strong>
<div className="small text-muted">Class section ID: {section.class_section_id}</div>
</div>
<div className="d-flex align-items-center gap-2 flex-wrap">
<span className={`badge ${submittedCount > 0 ? 'bg-warning text-dark' : 'bg-secondary'}`}>
New submissions: {submittedCount}
</span>
<span className="badge bg-light text-dark border">Total drafts: {sectionDrafts.length}</span>
</div>
</div>
</button>
</h2>
<div
id={collapseId}
className={`accordion-collapse collapse ${index === 0 ? 'show' : ''}`}
data-bs-parent="#examDraftsAccordion"
>
<div className="accordion-body">
{sectionDrafts.length === 0 ? (
<p className="text-muted mb-0">No submissions for this class yet.</p>
) : (
<div className="table-responsive">
<table className="table table-bordered align-middle exam-drafts-table">
<thead className="table-light">
<tr>
<th>Teacher</th>
<th>Title</th>
<th>Type</th>
<th>Version</th>
<th>Status</th>
<th>Files</th>
<th>Review</th>
</tr>
</thead>
<tbody>
{sectionDrafts.map((draft) => {
const teacherName = `${draft.teacher_first ?? ''} ${draft.teacher_last ?? ''}`.trim() || 'Admin upload'
const state = reviewState[draft.id] ?? { review_status: draft.status ?? '', admin_comments: draft.admin_comments ?? '', final_file: null }
const isFinalLegacy = Boolean(draft.is_legacy) && draft.status === 'finalized'
const teacherPath = teacherFilePath(draft.teacher_file)
const finalPath = finalFilePath(draft.final_pdf_file ?? draft.final_file)
return (
<tr key={draft.id}>
<td>{teacherName}</td>
<td>
<div className="fw-semibold">{draft.draft_title ?? 'Untitled'}</div>
<div className="small text-muted">{draft.description ?? ''}</div>
</td>
<td>{draft.exam_type ?? '—'}</td>
<td>{draft.version ?? 1}</td>
<td>
<span className={`badge ${statusBadgeClass(draft.status)}`}>{draft.status ?? '—'}</span>
</td>
<td className="text-nowrap" style={{ minWidth: 220 }}>
<div className="d-flex flex-wrap gap-2">
{teacherPath ? (
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => void openProtectedApiFile(teacherPath)}
>
Teacher file
</button>
) : null}
{finalPath ? (
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => void openProtectedApiFile(finalPath)}
>
Final file
</button>
) : null}
</div>
</td>
<td style={{ minWidth: 260 }}>
{isFinalLegacy ? (
<div className="text-muted small">Legacy upload is finalized automatically.</div>
) : (
<div className="vstack gap-2">
<select
className="form-select form-select-sm"
value={state.review_status}
onChange={(event) =>
setReviewState((current) => ({
...current,
[draft.id]: { ...state, review_status: event.target.value },
}))
}
>
<option value="">Select status</option>
{(data?.status_options ?? []).map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
<textarea
className="form-control form-control-sm"
rows={2}
value={state.admin_comments}
onChange={(event) =>
setReviewState((current) => ({
...current,
[draft.id]: { ...state, admin_comments: event.target.value },
}))
}
placeholder="Feedback for the teacher"
/>
<input
className="form-control form-control-sm"
type="file"
accept={allowedExtensions.map((ext) => `.${ext}`).join(',')}
onChange={(event) =>
setReviewState((current) => ({
...current,
[draft.id]: { ...state, final_file: event.target.files?.[0] ?? null },
}))
}
/>
<button
className="btn btn-sm btn-outline-primary"
type="button"
onClick={() => void submitReview(draft)}
>
Save Review
</button>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
) : (
<>
<div className="card border-primary-subtle mb-3">
<div className="card-header bg-light d-flex justify-content-between align-items-center">
<div>
<strong>Upload old / legacy exam</strong>
<div className="small text-muted">Store historic exams as accepted records.</div>
</div>
<span className="badge bg-primary-subtle text-primary">Admin only</span>
</div>
<div className="card-body">
<form className="row g-3" onSubmit={submitLegacy}>
<div className="col-md-4">
<label className="form-label small">Class section</label>
<select
className="form-select"
value={legacyForm.class_section_id}
onChange={(event) => setLegacyForm((current) => ({ ...current, class_section_id: event.target.value }))}
required
>
<option value="">Select class</option>
{classSections.map((section) => (
<option key={section.class_section_id} value={section.class_section_id}>
{section.class_section_name}
</option>
))}
</select>
</div>
<div className="col-md-2">
<label className="form-label small">School year</label>
<input
type="text"
className="form-control"
value={legacyForm.school_year}
onChange={(event) => setLegacyForm((current) => ({ ...current, school_year: event.target.value }))}
placeholder="2025-2026"
required
/>
</div>
<div className="col-md-2">
<label className="form-label small">Semester</label>
<input
type="text"
className="form-control"
value={legacyForm.semester}
onChange={(event) => setLegacyForm((current) => ({ ...current, semester: event.target.value }))}
placeholder="Fall / Spring"
required
/>
</div>
<div className="col-md-4">
<label className="form-label small">Exam type</label>
<select
className="form-select"
value={legacyForm.exam_type}
onChange={(event) => setLegacyForm((current) => ({ ...current, exam_type: event.target.value }))}
>
<option value=""> Select type </option>
{(data?.exam_types ?? []).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div className="col-12">
<label className="form-label small">Upload file</label>
<input
className="form-control"
type="file"
accept={allowedExtensions.map((ext) => `.${ext}`).join(',')}
onChange={(event) => setLegacyFile(event.target.files?.[0] ?? null)}
required
/>
<div className="form-text">Allowed: {allowedExtensions.join(', ')} Max {maxUploadMb} MB</div>
</div>
<div className="col-12">
<button className="btn btn-primary" type="submit" disabled={!legacyFile}>
Upload legacy exam
</button>
</div>
</form>
</div>
</div>
<div className="card mb-4">
<div className="card-body">
{Object.keys(legacyByClass).length === 0 ? (
<p className="text-muted mb-0">No legacy exams uploaded yet.</p>
) : (
Object.values(legacyByClass).map((group) => (
<div className="mb-4" key={group.class_section_id}>
<h6 className="mb-2">{group.class_section_name ?? 'Class'}</h6>
<div className="list-group">
{(group.items ?? []).map((item) => {
const finalPath = finalFilePath(item.final_pdf_file ?? item.final_file)
return (
<div className="list-group-item" key={item.id}>
<div className="d-flex flex-column flex-lg-row justify-content-between gap-2">
<div>
<div className="fw-semibold">{item.draft_title ?? item.exam_type ?? 'Legacy exam'}</div>
<div className="small text-muted">
{item.exam_type ?? '—'} {item.school_year ?? '—'} {item.semester ?? '—'}
</div>
</div>
<div className="d-flex align-items-center gap-2 flex-wrap">
<span className={`badge ${statusBadgeClass(item.status)}`}>{item.status ?? '—'}</span>
{finalPath ? (
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => void openProtectedApiFile(finalPath)}
>
View final
</button>
) : null}
</div>
</div>
</div>
)
})}
</div>
</div>
))
)}
</div>
</div>
</>
)}
</AdminParityShell>
)
}
export function AdminGradingManagementPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof fetchGradingOverview>> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeGradeKey, setActiveGradeKey] = useState<string>('')
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const result = await fetchGradingOverview()
if (cancelled) return
setData(result)
setActiveGradeKey(Object.keys(result.grades ?? {})[0] ?? '')
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load grading management.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const grades = data?.grades ?? {}
const studentsBySection = data?.students_by_section ?? {}
const actionTypes = ['homework', 'quiz', 'project', 'midterm', 'final', 'comments']
return (
<AdminParityShell title="Score Management">
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? (
<p className="text-muted">Loading grading overview</p>
) : Object.keys(grades).length === 0 ? (
<p className="text-muted">No grading sections found.</p>
) : (
<>
<ul className="nav nav-tabs justify-content-center flex-wrap">
{Object.keys(grades).map((classId) => (
<li key={classId} className="nav-item">
<button
type="button"
className={`nav-link ${classId === activeGradeKey ? 'active' : ''}`}
onClick={() => setActiveGradeKey(classId)}
>
{Number(classId) === 12 ? 'Youth' : `Grade ${classId}`}
</button>
</li>
))}
</ul>
<div className="mt-4">
{(grades[activeGradeKey] ?? []).map((section) => {
const rows: GradingOverviewStudentRow[] = studentsBySection[String(section.class_section_id)] ?? []
if (rows.length === 0) return null
return (
<div key={section.class_section_id} className="mb-4">
<h4 className="text-center mt-4">{section.class_section_name}</h4>
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead>
<tr>
<th>Student Name</th>
<th>School ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((student) => (
<tr key={student.id}>
<td>{`${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()}</td>
<td>{student.school_id ?? '—'}</td>
<td>
{actionTypes.map((type) => (
<Link
key={type}
className="btn btn-sm btn-outline-primary m-1"
to={`/app/grading/scores/${type}/${section.class_section_id}/${student.id}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Link>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
})}
</div>
</>
)}
</AdminParityShell>
)
}
export function AdminIpBansPage() {
const [status, setStatus] = useState<'all' | 'active'>('all')
const [rows, setRows] = useState<Array<Record<string, unknown>>>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [sortCol, setSortCol] = useState('id')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const [pageSize, setPageSize] = useState(25)
const [page, setPage] = useState(1)
async function load(currentStatus = status) {
setLoading(true)
try {
const data = await fetchIpBans({ status: currentStatus, perPage: 200 })
setRows((data.data?.bans as Array<Record<string, unknown>>) ?? [])
setPage(1)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load IP bans.')
} finally {
setLoading(false)
}
}
useEffect(() => { void load(status) }, [status])
function handleSort(col: string) {
if (col === sortCol) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortCol(col)
setSortDir('asc')
}
setPage(1)
}
const sortedRows = useMemo(() => {
const copy = [...rows]
copy.sort((a, b) => {
let av: string | number = String(a[sortCol] ?? '')
let bv: string | number = String(b[sortCol] ?? '')
if (['id', 'attempts'].includes(sortCol)) {
av = Number(av)
bv = Number(bv)
}
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return copy
}, [rows, sortCol, sortDir])
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize))
const pagedRows = sortedRows.slice((page - 1) * pageSize, page * pageSize)
async function onUnban(id?: number, all = false) {
await unbanIp(all ? { all: true } : { id })
await load()
}
async function onBan(id?: number) {
await banIp({ id, hours: 24 })
await load()
}
return (
<AdminParityShell title="IP Bans">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div className="d-flex align-items-center gap-2">
<label>Show</label>
<select className="form-select form-select-sm" style={{ minWidth: 160 }} value={status} onChange={(e) => { setStatus(e.target.value as 'all' | 'active'); setPage(1) }}>
<option value="all">All entries</option>
<option value="active">Active bans only</option>
</select>
<label className="ms-2">Per page</label>
<select className="form-select form-select-sm" style={{ width: 80 }} value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1) }}>
{[10, 25, 50, 100].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<button className="btn btn-danger btn-sm" type="button" onClick={() => void onUnban(undefined, true)}>
Unban All
</button>
</div>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead className="table-dark">
<tr>
<SortTh col="id" label="ID" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="ip_address" label="IP Address" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="attempts" label="Attempts" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="last_attempt_at" label="Last Attempt" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="blocked_until" label="Blocked Until" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="is_active" label="Status" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="created_at" label="Created" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="updated_at" label="Updated" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} className="text-muted">Loading IP bans</td></tr>
) : pagedRows.length === 0 ? (
<tr><td colSpan={9} className="text-muted">No IP ban records found.</td></tr>
) : pagedRows.map((row) => {
const id = Number(row.id ?? 0)
const active = Boolean(row.is_active)
return (
<tr key={id}>
<td>{id}</td>
<td>{String(row.ip_address ?? '—')}</td>
<td>{String(row.attempts ?? 0)}</td>
<td>{formatDate(String(row.last_attempt_at ?? ''))}</td>
<td>{formatDate(String(row.blocked_until ?? ''))}</td>
<td><span className={`badge ${active ? 'bg-danger' : 'bg-secondary'}`}>{active ? 'Active' : 'Not Active'}</span></td>
<td>{formatDate(String(row.created_at ?? ''))}</td>
<td>{formatDate(String(row.updated_at ?? ''))}</td>
<td className="d-flex gap-2">
{active ? (
<button className="btn btn-sm btn-primary" type="button" onClick={() => void onUnban(id)}>Unban</button>
) : (
<>
<button className="btn btn-sm btn-warning" type="button" onClick={() => void onBan(id)}>Ban 24h</button>
<button className="btn btn-sm btn-secondary" type="button" onClick={() => void onUnban(id)}>Clear Attempts</button>
</>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="d-flex align-items-center justify-content-between mt-2 flex-wrap gap-2">
<small className="text-muted">
Showing {Math.min((page - 1) * pageSize + 1, sortedRows.length)}{Math.min(page * pageSize, sortedRows.length)} of {sortedRows.length}
</small>
<nav>
<ul className="pagination pagination-sm mb-0">
<li className={`page-item ${page === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage(1)}>&laquo;</button>
</li>
<li className={`page-item ${page === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage((p) => p - 1)}>&lsaquo;</button>
</li>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2)
.reduce<(number | '…')[]>((acc, p, i, arr) => {
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('…')
acc.push(p)
return acc
}, [])
.map((p, i) =>
p === '…' ? (
<li key={`ellipsis-${i}`} className="page-item disabled"><span className="page-link"></span></li>
) : (
<li key={p} className={`page-item ${page === p ? 'active' : ''}`}>
<button className="page-link" onClick={() => setPage(p as number)}>{p}</button>
</li>
)
)}
<li className={`page-item ${page === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage((p) => p + 1)}>&rsaquo;</button>
</li>
<li className={`page-item ${page === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage(totalPages)}>&raquo;</button>
</li>
</ul>
</nav>
</div>
)}
</AdminParityShell>
)
}
export function AdminLateSlipLogsPage() {
const [filters, setFilters] = useState({ schoolYear: '', semester: '', q: '', dateFrom: '', dateTo: '' })
const [rows, setRows] = useState<Array<Record<string, unknown>>>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function load() {
setLoading(true)
try {
const data = await fetchLateSlipLogs({ ...filters, perPage: 100 })
setRows((data.data?.logs as Array<Record<string, unknown>>) ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load late slip logs.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
return (
<AdminParityShell title="Late Slip Logs">
{error ? <div className="alert alert-danger">{error}</div> : null}
<form className="row g-2 align-items-end mb-3" onSubmit={(e) => { e.preventDefault(); void load() }}>
<div className="col-auto"><label className="form-label">School Year</label><input className="form-control" value={filters.schoolYear} onChange={(e) => setFilters((c) => ({ ...c, schoolYear: e.target.value }))} /></div>
<div className="col-auto"><label className="form-label">Semester</label><select className="form-select" value={filters.semester} onChange={(e) => setFilters((c) => ({ ...c, semester: e.target.value }))}><option value=""></option><option value="Fall">Fall</option><option value="Spring">Spring</option></select></div>
<div className="col-auto"><label className="form-label">Student</label><input className="form-control" value={filters.q} onChange={(e) => setFilters((c) => ({ ...c, q: e.target.value }))} /></div>
<div className="col-auto"><label className="form-label">From</label><input type="date" className="form-control" value={filters.dateFrom} onChange={(e) => setFilters((c) => ({ ...c, dateFrom: e.target.value }))} /></div>
<div className="col-auto"><label className="form-label">To</label><input type="date" className="form-control" value={filters.dateTo} onChange={(e) => setFilters((c) => ({ ...c, dateTo: e.target.value }))} /></div>
<div className="col-auto d-flex gap-2"><button className="btn btn-primary" type="submit">Filter</button><button className="btn btn-secondary" type="button" onClick={() => { setFilters({ schoolYear: '', semester: '', q: '', dateFrom: '', dateTo: '' }); }}>Reset</button></div>
</form>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead><tr><th>ID</th><th>Printed At</th><th>Student</th><th>Grade</th><th>Date</th><th>Time In</th><th>Reason</th><th>School Year</th><th>Admin</th><th>Action</th></tr></thead>
<tbody>
{loading ? <tr><td colSpan={10} className="text-muted">Loading logs</td></tr> : rows.length === 0 ? <tr><td colSpan={10} className="text-muted">No late slip logs found.</td></tr> : rows.map((row) => (
<tr key={String(row.id ?? '')}>
<td>{String(row.id ?? '')}</td>
<td>{formatDate(String(row.printed_at ?? ''))}</td>
<td>{String(row.student_name ?? '—')}</td>
<td>{String(row.grade ?? '—')}</td>
<td>{formatDate(String(row.slip_date ?? ''))}</td>
<td>{String(row.time_in ?? '—')}</td>
<td>{String(row.reason ?? '—')}</td>
<td>{String(row.school_year ?? '—')}</td>
<td>{String(row.admin_name ?? '—')}</td>
<td><button className="btn btn-sm btn-outline-danger" type="button" onClick={() => void deleteLateSlipLog(Number(row.id)).then(load)}>Delete</button></td>
</tr>
))}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
export function AdminManageUsersPage() {
const [rows, setRows] = useState<UserListRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function load() {
setLoading(true)
try {
const data = await fetchUsers({ sort: 'lastname', order: 'asc' })
setRows(data.users ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load users.')
} finally {
setLoading(false)
}
}
useEffect(() => { void load() }, [])
return (
<AdminParityShell title="Manage Users">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="table-responsive">
<table className="table table-bordered mt-3 align-middle">
<thead><tr><th>ID</th><th>Name</th><th>Email</th><th>Role</th><th>Actions</th></tr></thead>
<tbody>
{loading ? <tr><td colSpan={5} className="text-muted">Loading users</td></tr> : rows.length === 0 ? <tr><td colSpan={5} className="text-muted">No users found.</td></tr> : rows.map((row) => {
const user = row.user ?? {}
const id = Number(user.id ?? 0)
const name = `${String(user.firstname ?? '')} ${String(user.lastname ?? '')}`.trim()
return (
<tr key={id}>
<td>{id}</td>
<td>{name || '—'}</td>
<td>{String(user.email ?? '—')}</td>
<td>
{(row.roles ?? [])
.map((role) => {
if (typeof role === 'string') return role
return String(role.name ?? role.slug ?? '')
})
.filter(Boolean)
.join(', ') || '—'}
</td>
<td><button className="btn btn-sm btn-danger" type="button" onClick={() => void deleteUser(id).then(load)}>Delete</button></td>
</tr>
)
})}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
function AdminNotificationMatrix({
title,
admins,
columns,
checkedMap,
onSave,
tableReady,
}: {
title: string
admins: NotificationAdminRow[]
columns: Array<{ key: string; label: string }>
checkedMap: Record<string, Record<string, boolean>>
onSave: (value: Record<string, Record<string, boolean>>) => Promise<void>
tableReady: boolean
}) {
const [state, setState] = useState(checkedMap)
useEffect(() => setState(checkedMap), [checkedMap])
return (
<AdminParityShell title={title}>
{!tableReady ? <div className="alert alert-warning">Notification subject storage is not available.</div> : null}
<div className="card">
<div className="card-header d-flex justify-content-between"><span>Admins</span><button className="btn btn-primary btn-sm" type="button" disabled={!tableReady} onClick={() => void onSave(state)}>Save</button></div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead className="table-dark"><tr><th>#</th><th>Name</th><th>Email</th>{columns.map((c) => <th key={c.key} className="text-center">{c.label}</th>)}</tr></thead>
<tbody>
{admins.length === 0 ? <tr><td colSpan={3 + columns.length} className="text-center text-muted">No admins found.</td></tr> : admins.map((admin, index) => {
const id = String(admin.id)
return (
<tr key={id}>
<td>{index + 1}</td>
<td>{`${admin.firstname ?? ''} ${admin.lastname ?? ''}`.trim() || `Admin #${id}`}</td>
<td>{admin.email ?? ''}</td>
{columns.map((c) => (
<td key={c.key} className="text-center">
<input className="form-check-input" type="checkbox" checked={Boolean(state[id]?.[c.key])} disabled={!tableReady} onChange={(e) => setState((current) => ({ ...current, [id]: { ...(current[id] ?? {}), [c.key]: e.target.checked } }))} />
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
</AdminParityShell>
)
}
export function AdminNotificationsAlertsPage() {
const [admins, setAdmins] = useState<NotificationAdminRow[]>([])
const [subjects, setSubjects] = useState<Record<string, string>>({})
const [assigned, setAssigned] = useState<Record<string, Record<string, boolean>>>({})
const [tableReady, setTableReady] = useState(false)
useEffect(() => {
;(async () => {
const data = await fetchNotificationAlerts()
setAdmins(data.admins ?? [])
setSubjects(data.subjects ?? {})
setAssigned(data.assignedSubjects ?? {})
setTableReady(Boolean(data.tableReady))
})()
}, [])
return (
<AdminNotificationMatrix
title="Admin Notification Subjects"
admins={admins}
columns={Object.entries(subjects).map(([key, label]) => ({ key, label }))}
checkedMap={assigned}
tableReady={tableReady}
onSave={async (value) => {
const payload: Record<string, string[]> = {}
for (const [adminId, map] of Object.entries(value)) {
payload[adminId] = Object.entries(map).filter(([, checked]) => checked).map(([key]) => key)
}
await saveNotificationAlerts(payload)
}}
/>
)
}
export function AdminPrintNotificationAdminsPage() {
const [admins, setAdmins] = useState<NotificationAdminRow[]>([])
const [assigned, setAssigned] = useState<Record<string, boolean>>({})
const [tableReady, setTableReady] = useState(false)
useEffect(() => {
;(async () => {
const data = await fetchPrintNotificationRecipients()
setAdmins(data.admins ?? [])
setAssigned(data.assigned ?? {})
setTableReady(Boolean(data.tableReady))
})()
}, [])
return (
<AdminNotificationMatrix
title="Print Notification Recipients"
admins={admins}
columns={[{ key: 'print_requests', label: 'Send Print Alerts' }]}
checkedMap={Object.fromEntries(Object.entries(assigned).map(([id, checked]) => [id, { print_requests: checked }]))}
tableReady={tableReady}
onSave={async (value) => {
const payload: Record<string, number> = {}
for (const [adminId, map] of Object.entries(value)) {
if (map.print_requests) payload[adminId] = 1
}
await savePrintNotificationRecipients(payload)
}}
/>
)
}
export function AdminParentProfilePage() {
const [guardians, setGuardians] = useState<NotificationAdminRow[]>([])
const [selectedGuardianId, setSelectedGuardianId] = useState<number | null>(null)
const [families, setFamilies] = useState<Array<Record<string, unknown>>>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
;(async () => {
setLoading(true)
try {
const data = await fetchFamilyAdminIndex()
setGuardians(data.data?.searchGuardians ?? [])
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
setError(null)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profiles.')
} finally {
setLoading(false)
}
})()
}, [])
async function loadGuardian(guardianId: number) {
setSelectedGuardianId(guardianId)
setError(null)
try {
const data = await fetchFamilyAdminIndex({ guardianId })
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load families.')
}
}
return (
<AdminParityShell title="Parent Profiles">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3">
<div className="col-lg-4">
<div className="card">
<div className="card-header">Guardians</div>
<div className="list-group list-group-flush" style={{ maxHeight: 520, overflow: 'auto' }}>
{loading ? <div className="list-group-item text-muted">Loading guardians</div> : guardians.map((guardian) => (
<button key={guardian.id} type="button" className={`list-group-item list-group-item-action ${selectedGuardianId === guardian.id ? 'active' : ''}`} onClick={() => void loadGuardian(guardian.id)}>
<div>{`${guardian.firstname ?? ''} ${guardian.lastname ?? ''}`.trim()}</div>
<div className="small">{guardian.email ?? ''}</div>
</button>
))}
</div>
</div>
</div>
<div className="col-lg-8">
<div className="card">
<div className="card-header">Families</div>
<div className="card-body">
{families.length === 0 ? <p className="text-muted mb-0">Select a guardian to view family details.</p> : families.map((family) => (
<div key={String(family.id ?? '')} className="border rounded p-3 mb-3">
<h5 className="mb-1">{String(family.household_name ?? 'Household')}</h5>
<div className="small text-muted mb-2">{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}</div>
<div className="mb-2"><strong>Phone:</strong> {String(family.primary_phone ?? '—')}</div>
<div><strong>Students</strong></div>
<ul className="mb-0">
{Array.isArray(family.students) && family.students.length > 0 ? family.students.map((student, idx) => (
<li key={idx}>{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()} {student.class_section_name ? `(${String(student.class_section_name)})` : ''}</li>
)) : <li className="text-muted">No students linked.</li>}
</ul>
</div>
))}
</div>
</div>
</div>
</div>
</AdminParityShell>
)
}
export function AdminPaypalTransactionsPage() {
const [q, setQ] = useState('')
const [rows, setRows] = useState<Array<Record<string, unknown>>>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function load(query = q) {
setLoading(true)
try {
const data = await fetchPaypalTransactions({ q: query || undefined, perPage: 100 })
setRows((data.transactions as Array<Record<string, unknown>>) ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load PayPal transactions.')
} finally {
setLoading(false)
}
}
useEffect(() => { void load('') }, [])
async function exportCsv() {
const blob = await fetchPaypalTransactionsCsv({ q: q || undefined })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'paypal_transactions.csv'
anchor.click()
URL.revokeObjectURL(url)
}
return (
<AdminParityShell title="PayPal Transactions">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex gap-2 mb-3 justify-content-end">
<input className="form-control" style={{ maxWidth: 320 }} placeholder="Search transactions" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn btn-primary" type="button" onClick={() => void load()}>Search</button>
<button className="btn btn-success" type="button" onClick={() => void exportCsv()}>Export CSV</button>
</div>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead><tr><th>ID</th><th>Transaction ID</th><th>Order ID</th><th>Parent School ID</th><th>Email</th><th>Amount</th><th>Net Amount</th><th>Currency</th><th>Status</th><th>Event Type</th><th>Date</th></tr></thead>
<tbody>
{loading ? <tr><td colSpan={11} className="text-muted">Loading transactions</td></tr> : rows.length === 0 ? <tr><td colSpan={11} className="text-muted">No transactions found.</td></tr> : rows.map((row) => (
<tr key={String(row.id ?? '')}>
<td>{String(row.id ?? '')}</td>
<td>{String(row.transaction_id ?? '—')}</td>
<td>{String(row.order_id ?? '—')}</td>
<td>{String(row.parent_school_id ?? '—')}</td>
<td>{String(row.payer_email ?? '—')}</td>
<td>{Number(row.amount ?? 0).toFixed(2)}</td>
<td>{Number(row.net_amount ?? 0).toFixed(2)}</td>
<td>{String(row.currency ?? '—')}</td>
<td>{String(row.status ?? '—')}</td>
<td>{String(row.event_type ?? '—')}</td>
<td>{formatDate(String(row.created_at ?? ''))}</td>
</tr>
))}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
function submissionBadgeClass(status?: {
label?: string | null
badge?: string | null
completed?: boolean
} | null) {
const badge = String(status?.badge ?? '').trim()
if (badge !== '') return badge
if (status?.completed === true) return 'bg-success'
if (status?.completed === false) return 'bg-danger'
return 'bg-secondary'
}
function submissionBadgeLabel(status?: {
label?: string | null
detail?: string | null
} | null) {
const label = String(status?.label ?? '').trim() || 'Missing'
const detail = String(status?.detail ?? '').trim()
return detail !== '' ? `${label} (${detail})` : label
}
export function AdminRemovedStudentsPage() {
const [schoolYear, setSchoolYear] = useState('')
const [activeStudents, setActiveStudents] = useState<RemovedStudentRow[]>([])
const [removedStudents, setRemovedStudents] = useState<RemovedStudentRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function load(year = schoolYear) {
setLoading(true)
try {
const data = await fetchRemovedStudents(year || undefined)
setActiveStudents(data.active_students ?? [])
setRemovedStudents(data.removed_students ?? [])
setSchoolYear(data.school_year ?? year ?? '')
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load removed students.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function toggleStudent(studentId: number, isActive: boolean) {
await setStudentActive({ student_id: studentId, is_active: isActive })
await load()
}
return (
<AdminParityShell title="Student Removal">
{error ? <div className="alert alert-danger">{error}</div> : null}
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="row g-4">
<div className="col-xl-6">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<span>Active Students</span>
<span className="badge bg-primary">{activeStudents.length}</span>
</div>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle mb-0">
<thead className="table-dark">
<tr><th>School ID</th><th>First Name</th><th>Last Name</th><th>Class</th><th>Action</th></tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={5} className="text-muted">Loading students</td></tr> : activeStudents.length === 0 ? <tr><td colSpan={5} className="text-muted">No active students found.</td></tr> : activeStudents.map((student) => (
<tr key={student.id}>
<td>{student.school_id ?? '—'}</td>
<td>{student.firstname ?? '—'}</td>
<td>{student.lastname ?? '—'}</td>
<td>{student.class_section_name ?? 'No class assigned'}</td>
<td><button className="btn btn-outline-danger btn-sm" type="button" onClick={() => void toggleStudent(student.id, false)}>Remove</button></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="col-xl-6">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<span>Removed Students</span>
<span className="badge bg-secondary">{removedStudents.length}</span>
</div>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle mb-0">
<thead className="table-dark">
<tr><th>School ID</th><th>First Name</th><th>Last Name</th><th>Last Class</th><th>Action</th></tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={5} className="text-muted">Loading students</td></tr> : removedStudents.length === 0 ? <tr><td colSpan={5} className="text-muted">No removed students found.</td></tr> : removedStudents.map((student) => (
<tr key={student.id}>
<td>{student.school_id ?? '—'}</td>
<td>{student.firstname ?? '—'}</td>
<td>{student.lastname ?? '—'}</td>
<td>{student.class_section_name ?? 'No class assigned'}</td>
<td><button className="btn btn-outline-success btn-sm" type="button" onClick={() => void toggleStudent(student.id, true)}>Restore</button></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminParityShell>
)
}
export function AdminSearchResultsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const initialQuery = searchParams.get('query') ?? ''
const [query, setQuery] = useState(initialQuery)
const [result, setResult] = useState<AdministratorDashboardSearchResponse | null>(null)
const [loading, setLoading] = useState(Boolean(initialQuery))
const [error, setError] = useState<string | null>(null)
async function runSearch(value: string) {
const trimmed = value.trim()
if (!trimmed) {
setResult(null)
setSearchParams({})
return
}
setLoading(true)
try {
const data = await searchAdministratorDashboard(trimmed)
setResult(data)
setSearchParams({ query: trimmed })
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load search results.')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (initialQuery) void runSearch(initialQuery)
}, [])
const groups = [
{ key: 'users', label: 'Users' },
{ key: 'students', label: 'Students' },
{ key: 'parents', label: 'Parents' },
{ key: 'staff', label: 'Staff' },
{ key: 'emergency_contacts', label: 'Emergency Contacts' },
] as const
return (
<AdminParityShell title="Search Results">
{error ? <div className="alert alert-danger">{error}</div> : null}
<form className="d-flex gap-2 mb-4" onSubmit={(e) => { e.preventDefault(); void runSearch(query) }}>
<input className="form-control" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Enter search term" required />
<button className="btn btn-primary" type="submit">Search</button>
</form>
{!result && !loading ? <p className="text-muted mb-0">Enter a search term to view results.</p> : null}
{loading ? <p className="text-muted">Loading search results</p> : null}
{result && groups.map((group) => {
const rows = result.results?.[group.key] ?? []
if (!rows || rows.length === 0) return null
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))))
return (
<div key={group.key} className="card shadow-sm mb-4">
<div className="card-header">{group.label}</div>
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle mb-0">
<thead className="table-light">
<tr>{columns.map((column) => <th key={column}>{column.replace(/_/g, ' ')}</th>)}</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${group.key}-${index}`}>
{columns.map((column) => <td key={column}>{String(row[column] ?? '—')}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
})}
{result && groups.every((group) => (result.results?.[group.key] ?? []).length === 0) ? (
<p className="text-muted">No results found.</p>
) : null}
</AdminParityShell>
)
}
/** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */
export function AdminSectionsPromotionTotalsPage() {
const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function load(year = schoolYear) {
setLoading(true)
setError(null)
try {
const data = await fetchStudentPromotionTotals(year || undefined)
setRows(data.rows ?? [])
setSchoolYear(data.year ?? year ?? '')
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load promotion totals.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
return (
<AdminParityShell title="Section promotion totals">
<p className="text-muted small mb-3">
Per-class/section student counts from the promotion pipeline. Pair with{' '}
<Link to="/app/administrator/sections/auto-distribute">auto-distribute sections</Link> to create
sections from these totals.
</p>
<div className="d-flex flex-wrap align-items-start justify-content-between gap-2 mb-3">
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<Link className="btn btn-outline-primary" to={`/app/administrator/sections/auto-distribute${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
Auto-distribute sections
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead className="table-light">
<tr>
<th>Class / section</th>
<th className="text-end">Total students</th>
<th className="text-muted small">class_id</th>
<th className="text-muted small">class_section_id</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="text-muted">
Loading
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={4} className="text-muted">
No promotion totals for this school year.
</td>
</tr>
) : (
rows.map((row, i) => {
const key = String(row.class_section_id ?? row.class_id ?? i)
return (
<tr key={key}>
<td>{row.class_section_name ?? '—'}</td>
<td className="text-end">{Number(row.total ?? 0)}</td>
<td className="text-muted small">{row.class_id ?? '—'}</td>
<td className="text-muted small">{row.class_section_id ?? '—'}</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
export function AdminSectionsAutoDistributePage() {
const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [studentsPerSection, setStudentsPerSection] = useState(20)
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState<string | null>(null)
const [distribution, setDistribution] = useState<Record<string, string[]>>({})
async function load(year = schoolYear) {
setLoading(true)
try {
const data = await fetchStudentPromotionTotals(year || undefined)
setRows(data.rows ?? [])
setSchoolYear(data.year ?? year ?? '')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function runDistribution(row: StudentPromotionTotalsRow) {
const response = await autoDistributeStudents({
class_id: row.class_id ?? undefined,
class_section_id: row.class_section_id ?? undefined,
students_per_section: studentsPerSection,
school_year: schoolYear || undefined,
})
const sections = (response.data?.sections ?? []).map((section) => {
const name = String(section.class_section_name ?? 'Section')
const total = Number(section.total ?? 0)
return `${name}: ${total}`
})
setDistribution((current) => ({ ...current, [String(row.class_section_id ?? row.class_id ?? '')]: sections }))
setMessage(response.message ?? 'Distribution completed.')
}
async function runAll() {
for (const row of rows) {
// eslint-disable-next-line no-await-in-loop
await runDistribution(row)
}
}
return (
<AdminParityShell title="Auto-Distribute Students into Sections">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-2 align-items-end mb-3">
<div className="col-lg-6">
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
</div>
<div className="col-sm-4 col-lg-3">
<label className="form-label">Students per section</label>
<input type="number" min={1} className="form-control" value={studentsPerSection} onChange={(e) => setStudentsPerSection(Number(e.target.value) || 1)} />
</div>
<div className="col-auto"><button className="btn btn-primary" type="button" onClick={() => void runAll()} disabled={rows.length === 0}>Generate All</button></div>
</div>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead className="table-light">
<tr><th>Class</th><th>Total</th><th>Sections Needed</th><th>Generated Sections</th><th>Actions</th></tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={5} className="text-muted">Loading totals</td></tr> : rows.length === 0 ? <tr><td colSpan={5} className="text-muted">No section totals found.</td></tr> : rows.map((row) => {
const total = Number(row.total ?? 0)
const needed = studentsPerSection > 0 ? Math.ceil(total / studentsPerSection) : 0
const key = String(row.class_section_id ?? row.class_id ?? '')
return (
<tr key={key}>
<td>{row.class_section_name ?? '—'}</td>
<td>{total}</td>
<td>{needed || '—'}</td>
<td>{distribution[key]?.join(', ') ?? '—'}</td>
<td><button className="btn btn-sm btn-primary" type="button" onClick={() => void runDistribution(row)}>Generate Sections</button></td>
</tr>
)
})}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
export function AdminStudentClassAssignmentPage() {
const [searchParams] = useSearchParams()
type StudentAssignmentSortKey =
| 'name'
| 'age'
| 'registration_grade'
| 'new_student'
| 'assigned_classes'
| 'registration_date'
| 'school_year'
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [students, setStudents] = useState<StudentAssignmentRow[]>([])
const [classes, setClasses] = useState<StudentAssignmentOption[]>([])
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
const [selectedClassIds, setSelectedClassIds] = useState<number[]>([])
const [eventOnly, setEventOnly] = useState(false)
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState<string | null>(null)
const [tableSearch, setTableSearch] = useState('')
const [sortConfig, setSortConfig] = useState<{
key: StudentAssignmentSortKey
direction: 'asc' | 'desc'
}>({
key: 'name',
direction: 'asc',
})
async function load(year = schoolYear) {
setLoading(true)
try {
const data = await fetchStudentAssignments(year || undefined)
setStudents(data.students ?? [])
setClasses(data.classes ?? [])
setSchoolYear(data.selectedYear ?? year ?? '')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function submitAssignment() {
if (!selectedStudentId || selectedClassIds.length === 0) return
const result = await assignStudentClass({
student_id: selectedStudentId,
class_section_id: selectedClassIds,
is_event_only: eventOnly,
})
setMessage(result.message ?? 'Classes assigned successfully.')
setSelectedStudentId(null)
setSelectedClassIds([])
setEventOnly(false)
await load()
}
async function removeAssignment(studentId: number, classSectionId: number) {
const result = await removeStudentClass({ student_id: studentId, class_section_id: classSectionId })
setMessage(result.message ?? 'Class removed successfully.')
await load()
}
function startAssign(student: StudentAssignmentRow) {
setSelectedStudentId(student.student_id)
setSelectedClassIds(student.class_section_ids ?? [])
setEventOnly(false)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function requestSort(key: StudentAssignmentSortKey) {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'asc' },
)
}
function sortArrow(key: StudentAssignmentSortKey) {
if (sortConfig.key !== key) return '↕'
return sortConfig.direction === 'asc' ? '↑' : '↓'
}
const displayedStudents = useMemo(() => {
const query = tableSearch.trim().toLowerCase()
const filtered = students.filter((student) => {
if (!query) return true
const haystack = [
student.name,
student.registration_grade,
student.new_student,
student.school_year,
student.registration_date,
...(student.class_section_names ?? []),
]
.map((value) => String(value ?? '').toLowerCase())
.join(' ')
return haystack.includes(query)
})
return [...filtered].sort((left, right) => {
const direction = sortConfig.direction === 'asc' ? 1 : -1
if (sortConfig.key === 'age') {
const a = Number(left.age ?? 0)
const b = Number(right.age ?? 0)
return (a - b) * direction
}
if (sortConfig.key === 'assigned_classes') {
const a = (left.class_section_names ?? []).join(', ')
const b = (right.class_section_names ?? []).join(', ')
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * direction
}
const a = String(left[sortConfig.key] ?? '')
const b = String(right[sortConfig.key] ?? '')
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * direction
})
}, [students, tableSearch, sortConfig])
return (
<AdminParityShell title="Student Class Assignment">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-3 mb-4">
<div className="col-lg-4">
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
</div>
<div className="col-lg-8">
<div className="card shadow-sm">
<div className="card-body row g-2 align-items-end">
<div className="col-md-4">
<label className="form-label">Student</label>
<select className="form-select" value={selectedStudentId ?? ''} onChange={(e) => setSelectedStudentId(e.target.value ? Number(e.target.value) : null)}>
<option value="">Select student</option>
{students.map((student) => <option key={student.student_id} value={student.student_id}>{student.name}</option>)}
</select>
</div>
<div className="col-md-5">
<label className="form-label">Class(es)</label>
<select
className="form-select"
multiple
size={6}
value={selectedClassIds.map(String)}
onChange={(e) => setSelectedClassIds(Array.from(e.target.selectedOptions).map((option) => Number(option.value)))}
>
{classes.map((classRow) => <option key={classRow.class_section_id} value={classRow.class_section_id}>{classRow.class_section_name}</option>)}
</select>
</div>
<div className="col-md-3">
<div className="form-check mb-2">
<input id="eventOnly" className="form-check-input" type="checkbox" checked={eventOnly} onChange={(e) => setEventOnly(e.target.checked)} />
<label className="form-check-label" htmlFor="eventOnly">Event class only</label>
</div>
<button className="btn btn-primary w-100" type="button" onClick={() => void submitAssignment()} disabled={!selectedStudentId || selectedClassIds.length === 0}>Assign Class</button>
</div>
</div>
</div>
</div>
</div>
<div className="d-flex justify-content-end mb-3">
<div className="input-group" style={{ maxWidth: 360 }}>
<input
className="form-control"
value={tableSearch}
onChange={(event) => setTableSearch(event.target.value)}
placeholder="Search students or classes"
/>
<button className="btn btn-outline-secondary" type="button" onClick={() => setTableSearch('')}>
Clear
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead className="table-dark">
<tr>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('name')}>Student Name {sortArrow('name')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('age')}>Age {sortArrow('age')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('registration_grade')}>Registration Grade {sortArrow('registration_grade')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('new_student')}>New Student {sortArrow('new_student')}</th>
<th>Assign Class</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('assigned_classes')}>Assigned Classes {sortArrow('assigned_classes')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('registration_date')}>Registration Date {sortArrow('registration_date')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('school_year')}>School Year {sortArrow('school_year')}</th>
</tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={8} className="text-muted">Loading students</td></tr> : displayedStudents.length === 0 ? <tr><td colSpan={8} className="text-muted">No students found.</td></tr> : displayedStudents.map((student) => (
<tr key={student.student_id}>
<td>{student.name}</td>
<td>{student.age ?? '—'}</td>
<td>{student.registration_grade ?? '—'}</td>
<td>{student.new_student ?? '—'}</td>
<td>
<button
className="btn btn-primary btn-sm"
type="button"
onClick={() => startAssign(student)}
>
Assign Class
</button>
</td>
<td>
{student.class_section_ids && student.class_section_ids.length > 0 ? (
<div className="d-flex flex-wrap gap-2">
{student.class_section_ids.map((classId, index) => (
<span key={`${student.student_id}-${classId}`} className="badge bg-success d-inline-flex align-items-center gap-2">
<span>{student.class_section_names?.[index] ?? `Class ${classId}`}</span>
<button className="btn-close btn-close-white" style={{ fontSize: 10 }} type="button" aria-label="Remove class" onClick={() => void removeAssignment(student.student_id, classId)} />
</span>
))}
</div>
) : (
<span className="text-muted">No class assigned</span>
)}
</td>
<td>{formatDate(student.registration_date)}</td>
<td>{student.school_year ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
export function AdminStudentProfilesPage() {
const [searchParams] = useSearchParams()
type StudentProfileSortKey =
| 'school_id'
| 'firstname'
| 'lastname'
| 'dob'
| 'age'
| 'gender'
| 'registration_grade'
| 'is_active'
| 'photo_consent'
| 'registration_date'
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [students, setStudents] = useState<StudentDirectoryRow[]>([])
const [editing, setEditing] = useState<StudentDirectoryRow | null>(null)
const [parentDetails, setParentDetails] = useState<Record<string, unknown> | null>(null)
const [emergencyContacts, setEmergencyContacts] = useState<Array<Record<string, unknown>>>([])
const [message, setMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [tableSearch, setTableSearch] = useState('')
const [sortConfig, setSortConfig] = useState<{
key: StudentProfileSortKey
direction: 'asc' | 'desc'
}>({
key: 'lastname',
direction: 'asc',
})
async function load(year = schoolYear) {
setLoading(true)
try {
const data = await fetchStudentDirectory(year || undefined)
setStudents(data.students ?? [])
setSchoolYear(data.school_year ?? year ?? '')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function openDetails(student: StudentDirectoryRow) {
setEditing(student)
const [parent, contacts] = await Promise.all([
fetchStudentParent(student.id),
fetchStudentEmergencyContacts(student.id),
])
setParentDetails(parent.parent ?? null)
setEmergencyContacts(contacts.rows ?? [])
}
async function saveStudent() {
if (!editing) return
const result = await updateStudentProfile(editing.id, {
school_id: editing.school_id ?? '',
firstname: editing.firstname ?? '',
lastname: editing.lastname ?? '',
dob: editing.dob ?? '',
age: editing.age ?? null,
gender: editing.gender ?? 'Male',
registration_grade: editing.registration_grade ?? '',
photo_consent: Boolean(editing.photo_consent),
parent_id: editing.parent_id ?? 0,
registration_date: editing.registration_date ?? null,
tuition_paid: Boolean(editing.tuition_paid),
year_of_registration: editing.year_of_registration ?? '',
school_year: editing.school_year ?? '',
semester: editing.semester ?? '',
is_new: Boolean(editing.is_new),
is_active: Boolean(editing.is_active),
medical_conditions: editing.medical_conditions ?? '',
allergies: editing.allergies ?? '',
})
setMessage(result.message ?? 'Student updated successfully.')
setEditing(null)
await load()
}
function requestSort(key: StudentProfileSortKey) {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'asc' },
)
}
function sortArrow(key: StudentProfileSortKey) {
if (sortConfig.key !== key) return '↕'
return sortConfig.direction === 'asc' ? '↑' : '↓'
}
const displayedStudents = useMemo(() => {
const query = tableSearch.trim().toLowerCase()
const filtered = students.filter((student) => {
if (!query) return true
const haystack = [
student.school_id,
student.firstname,
student.lastname,
student.gender,
student.registration_grade,
student.dob,
student.registration_date,
Boolean(student.is_active) ? 'yes active' : 'no inactive',
Boolean(student.photo_consent) ? 'yes consent' : 'no consent',
]
.map((value) => String(value ?? '').toLowerCase())
.join(' ')
return haystack.includes(query)
})
return [...filtered].sort((left, right) => {
const direction = sortConfig.direction === 'asc' ? 1 : -1
if (sortConfig.key === 'age') {
const a = Number(left.age ?? 0)
const b = Number(right.age ?? 0)
return (a - b) * direction
}
if (sortConfig.key === 'is_active' || sortConfig.key === 'photo_consent') {
const a = Number(Boolean(left[sortConfig.key]))
const b = Number(Boolean(right[sortConfig.key]))
return (a - b) * direction
}
const a = String(left[sortConfig.key] ?? '')
const b = String(right[sortConfig.key] ?? '')
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * direction
})
}, [students, tableSearch, sortConfig])
return (
<AdminParityShell title="Student Profiles">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="d-flex flex-wrap gap-2 mb-3 justify-content-between">
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="input-group" style={{ maxWidth: 360 }}>
<input
className="form-control"
value={tableSearch}
onChange={(event) => setTableSearch(event.target.value)}
placeholder="Search students"
/>
<button className="btn btn-outline-secondary" type="button" onClick={() => setTableSearch('')}>
Clear
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead className="table-dark">
<tr>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('school_id')}>School ID {sortArrow('school_id')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('firstname')}>First Name {sortArrow('firstname')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('lastname')}>Last Name {sortArrow('lastname')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('dob')}>DOB {sortArrow('dob')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('age')}>Age {sortArrow('age')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('gender')}>Gender {sortArrow('gender')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('registration_grade')}>Assigned Grade {sortArrow('registration_grade')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('is_active')}>Active {sortArrow('is_active')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('photo_consent')}>Photo Consent {sortArrow('photo_consent')}</th>
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('registration_date')}>Registration Date {sortArrow('registration_date')}</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={11} className="text-muted">Loading students</td></tr> : displayedStudents.length === 0 ? <tr><td colSpan={11} className="text-muted">No students found.</td></tr> : displayedStudents.map((student) => (
<tr key={student.id}>
<td>{student.school_id ?? '—'}</td>
<td>{student.firstname ?? '—'}</td>
<td>{student.lastname ?? '—'}</td>
<td>{formatDate(student.dob)}</td>
<td>{student.age ?? '—'}</td>
<td>{student.gender ?? '—'}</td>
<td>{student.registration_grade ?? '—'}</td>
<td>{Boolean(student.is_active) ? 'Yes' : 'No'}</td>
<td>{Boolean(student.photo_consent) ? 'Yes' : 'No'}</td>
<td>{formatDate(student.registration_date)}</td>
<td><button className="btn btn-sm btn-primary" type="button" onClick={() => void openDetails(student)}>View / Edit</button></td>
</tr>
))}
</tbody>
</table>
</div>
{editing ? (
<div className="modal d-block position-static mt-4" role="dialog" aria-modal="true">
<div className="modal-dialog modal-xl">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Edit Student - {`${editing.firstname ?? ''} ${editing.lastname ?? ''}`.trim()}</h5>
<button className="btn-close" type="button" onClick={() => setEditing(null)} />
</div>
<div className="modal-body">
<div className="row g-3">
<div className="col-md-4"><label className="form-label">School ID</label><input className="form-control" value={editing.school_id ?? ''} onChange={(e) => setEditing((current) => current ? { ...current, school_id: e.target.value } : current)} /></div>
<div className="col-md-4"><label className="form-label">First Name</label><input className="form-control" value={editing.firstname ?? ''} onChange={(e) => setEditing((current) => current ? { ...current, firstname: e.target.value } : current)} /></div>
<div className="col-md-4"><label className="form-label">Last Name</label><input className="form-control" value={editing.lastname ?? ''} onChange={(e) => setEditing((current) => current ? { ...current, lastname: e.target.value } : current)} /></div>
<div className="col-md-3"><label className="form-label">DOB</label><input type="date" className="form-control" value={formatDateInput(editing.dob)} onChange={(e) => setEditing((current) => current ? { ...current, dob: e.target.value } : current)} /></div>
<div className="col-md-2"><label className="form-label">Age</label><input type="number" className="form-control" value={editing.age ?? ''} onChange={(e) => setEditing((current) => current ? { ...current, age: Number(e.target.value) || null } : current)} /></div>
<div className="col-md-3"><label className="form-label">Gender</label><select className="form-select" value={editing.gender ?? 'Male'} onChange={(e) => setEditing((current) => current ? { ...current, gender: e.target.value } : current)}><option value="Male">Male</option><option value="Female">Female</option><option value="Other">Other</option></select></div>
<div className="col-md-4"><label className="form-label">Registration Grade</label><input className="form-control" value={editing.registration_grade ?? ''} onChange={(e) => setEditing((current) => current ? { ...current, registration_grade: e.target.value } : current)} /></div>
<div className="col-md-6"><label className="form-label">Medical Conditions</label><input className="form-control" value={editing.medical_conditions ?? ''} onChange={(e) => setEditing((current) => current ? { ...current, medical_conditions: e.target.value } : current)} /></div>
<div className="col-md-6"><label className="form-label">Allergies</label><input className="form-control" value={editing.allergies ?? ''} onChange={(e) => setEditing((current) => current ? { ...current, allergies: e.target.value } : current)} /></div>
</div>
<hr />
<div className="row g-4">
<div className="col-md-6">
<h6>Parent Information</h6>
{parentDetails ? (
<div className="small">
<div>{`${String(parentDetails.firstname ?? '')} ${String(parentDetails.lastname ?? '')}`.trim()}</div>
<div>{String(parentDetails.email ?? '—')}</div>
<div>{String(parentDetails.cellphone ?? '—')}</div>
</div>
) : <p className="text-muted small mb-0">No parent record found.</p>}
</div>
<div className="col-md-6">
<h6>Emergency Contacts</h6>
{emergencyContacts.length === 0 ? <p className="text-muted small mb-0">No emergency contacts found.</p> : (
<ul className="small mb-0">
{emergencyContacts.map((contact, index) => <li key={index}>{String(contact.emergency_contact_name ?? contact.name ?? 'Contact')} - {String(contact.cellphone ?? '—')}</li>)}
</ul>
)}
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" type="button" onClick={() => setEditing(null)}>Close</button>
<button className="btn btn-primary" type="button" onClick={() => void saveStudent()}>Save</button>
</div>
</div>
</div>
</div>
) : null}
</AdminParityShell>
)
}
export function AdminSubjectCurriculumPage() {
const [searchParams] = useSearchParams()
const [classes, setClasses] = useState<Array<{ id: number; class_name: string }>>([])
const [entries, setEntries] = useState<SubjectCurriculumEntryRow[]>([])
const [subjects, setSubjects] = useState<Record<string, string>>({})
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [form, setForm] = useState({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
const [editingId, setEditingId] = useState<number | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
async function load(year = schoolYear) {
setLoading(true)
setError(null)
try {
const data = await fetchSubjectCurriculum(year || undefined)
setClasses(data.classes ?? [])
setEntries(data.entries ?? [])
setSubjects(data.subject_labels ?? {})
setSchoolYear(data.school_year ?? year ?? '')
} catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function submitForm(e: FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
const payload = {
class_id: Number(form.class_id),
subject: form.subject,
unit_number: form.unit_number ? Number(form.unit_number) : null,
unit_title: form.unit_title || null,
chapter_name: form.chapter_name,
school_year: schoolYear || undefined,
}
try {
if (editingId) {
await updateSubjectCurriculum(editingId, payload)
setMessage('Curriculum entry updated.')
} else {
await createSubjectCurriculum(payload)
setMessage('Curriculum entry created.')
}
setEditingId(null)
setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
await load()
} catch (e: unknown) {
setMessage(null)
setError(e instanceof ApiHttpError ? e.message : 'Unable to save curriculum entry.')
} finally {
setSaving(false)
}
}
async function removeEntry(id: number) {
if (!confirm('Delete this curriculum entry?')) return
setError(null)
try {
await deleteSubjectCurriculum(id)
setMessage('Curriculum entry deleted.')
await load()
} catch (e: unknown) {
setMessage(null)
setError(e instanceof ApiHttpError ? e.message : 'Unable to delete curriculum entry.')
}
}
return (
<AdminParityShell title="Subject Curriculum">
{message ? <div className="alert alert-info">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="row g-4">
<div className="col-lg-5">
<div className="card shadow-sm">
<div className="card-header">{editingId ? 'Edit curriculum entry' : 'Add new curriculum entry'}</div>
<div className="card-body">
<form onSubmit={(e) => void submitForm(e)}>
<fieldset disabled={loading || saving} className="border-0 m-0 p-0">
<div className="mb-3">
<label className="form-label">Class</label>
<select className="form-select" value={form.class_id} onChange={(e) => setForm((current) => ({ ...current, class_id: e.target.value }))} required>
<option value="">Select a class</option>
{classes.map((classRow) => <option key={classRow.id} value={classRow.id}>{classRow.class_name}</option>)}
</select>
</div>
<div className="mb-3">
<label className="form-label">Subject</label>
<select className="form-select" value={form.subject} onChange={(e) => setForm((current) => ({ ...current, subject: e.target.value }))} required>
{Object.entries(subjects).map(([key, label]) => <option key={key} value={key}>{label}</option>)}
</select>
</div>
<div className="mb-3"><label className="form-label">Unit / Term number</label><input type="number" className="form-control" value={form.unit_number} onChange={(e) => setForm((current) => ({ ...current, unit_number: e.target.value }))} /></div>
<div className="mb-3"><label className="form-label">Unit title</label><input className="form-control" value={form.unit_title} onChange={(e) => setForm((current) => ({ ...current, unit_title: e.target.value }))} /></div>
<div className="mb-3"><label className="form-label">Chapter / Surah</label><input className="form-control" value={form.chapter_name} onChange={(e) => setForm((current) => ({ ...current, chapter_name: e.target.value }))} required /></div>
<div className="d-flex gap-2">
<button
className="btn btn-primary flex-grow-1"
type="submit"
disabled={loading || saving}
>
{saving ? 'Saving…' : editingId ? 'Apply changes' : 'Save curriculum entry'}
</button>
{editingId ? (
<button
className="btn btn-outline-secondary"
type="button"
disabled={loading || saving}
onClick={() => {
setEditingId(null)
setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
}}
>
Cancel
</button>
) : null}
</div>
</fieldset>
</form>
</div>
</div>
</div>
<div className="col-lg-7">
<div className="card shadow-sm">
<div className="card-header">Current curriculum entries</div>
<div className="table-responsive">
<table className="table table-bordered table-hover align-middle mb-0">
<thead className="table-light"><tr><th>Class</th><th>School Year</th><th>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="text-muted">
Loading
</td>
</tr>
) : entries.length === 0 ? (
<tr>
<td colSpan={7} className="text-muted">
No curriculum records yet.
</td>
</tr>
) : (
entries.map((entry) => (
<tr key={entry.id}>
<td>{entry.class_name ?? '—'}</td>
<td>{entry.school_year ?? schoolYear ?? '—'}</td>
<td>{subjects[entry.subject] ?? entry.subject}</td>
<td>{entry.unit_number ?? '—'}</td>
<td>{entry.unit_title ?? '—'}</td>
<td>{entry.chapter_name}</td>
<td className="d-flex gap-2">
<button
className="btn btn-sm btn-outline-primary"
type="button"
disabled={saving}
onClick={() => {
setEditingId(entry.id)
setForm({
class_id: String(entry.class_id),
subject: entry.subject,
unit_number: entry.unit_number ? String(entry.unit_number) : '',
unit_title: entry.unit_title ?? '',
chapter_name: entry.chapter_name,
})
}}
>
Edit
</button>
<button
className="btn btn-sm btn-outline-danger"
type="button"
disabled={saving}
onClick={() => void removeEntry(entry.id)}
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminParityShell>
)
}
export function AdminTeacherClassAssignmentPage() {
const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [teachers, setTeachers] = useState<TeacherClassAssignmentRow[]>([])
const [classes, setClasses] = useState<TeacherClassOption[]>([])
const [selectedTeacherId, setSelectedTeacherId] = useState<number | null>(null)
const [selectedClassId, setSelectedClassId] = useState<number | null>(null)
const [selectedRole, setSelectedRole] = useState('')
const [message, setMessage] = useState<string | null>(null)
async function load(year = schoolYear) {
const data = await fetchTeacherClassAssignments(year || undefined)
setTeachers(data.teachers ?? [])
setClasses(data.classes ?? [])
setSchoolYear(data.school_year ?? year ?? '')
}
useEffect(() => {
void load()
}, [])
async function submitAssignment() {
if (!selectedTeacherId || !selectedClassId) return
const teacher = teachers.find((row) => row.teacher_id === selectedTeacherId)
const result = await assignTeacherClass({
teacher_id: selectedTeacherId,
class_section_id: selectedClassId,
teacher_role: selectedRole || (teacher?.role ?? undefined),
school_year: schoolYear || undefined,
})
setMessage(result.message ?? 'Class assigned successfully.')
setSelectedTeacherId(null)
setSelectedClassId(null)
setSelectedRole('')
await load()
}
async function removeAssignment(teacherId: number, classSectionId: number, position: 'main' | 'ta') {
const result = await deleteTeacherClassAssignment({
teacher_id: teacherId,
class_section_id: classSectionId,
position,
school_year: schoolYear || undefined,
})
setMessage(result.message ?? 'Assignment removed.')
await load()
}
return (
<AdminParityShell title="Teacher Class Assignments">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-3 mb-4">
<div className="col-lg-3"><CiAcademicFilter selectedYear={schoolYear} onApply={({ schoolYear: year }) => void load(year)} /></div>
<div className="col-lg-3"><label className="form-label">Teacher</label><select className="form-select" value={selectedTeacherId ?? ''} onChange={(e) => { const id = e.target.value ? Number(e.target.value) : null; setSelectedTeacherId(id); const teacher = teachers.find((row) => row.teacher_id === id); setSelectedRole(String(teacher?.role ?? '')) }}><option value="">Select teacher</option>{teachers.map((teacher) => <option key={teacher.teacher_id} value={teacher.teacher_id}>{teacher.name ?? `${teacher.firstname ?? ''} ${teacher.lastname ?? ''}`.trim()}</option>)}</select></div>
<div className="col-lg-4"><label className="form-label">Class</label><select className="form-select" value={selectedClassId ?? ''} onChange={(e) => setSelectedClassId(e.target.value ? Number(e.target.value) : null)}><option value="">Select class</option>{classes.map((classRow) => <option key={classRow.class_section_id} value={classRow.class_section_id}>{classRow.class_section_name}</option>)}</select></div>
<div className="col-lg-2 d-grid"><button className="btn btn-primary align-self-end" type="button" onClick={() => void submitAssignment()} disabled={!selectedTeacherId || !selectedClassId}>Assign Class</button></div>
</div>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead className="table-dark"><tr><th>#</th><th>First Name</th><th>Last Name</th><th>Email</th><th>Phone</th><th>Role</th><th>Assigned Classes</th><th>School Year</th></tr></thead>
<tbody>
{teachers.length === 0 ? <tr><td colSpan={8} className="text-muted">No teachers found.</td></tr> : teachers.map((teacher, index) => (
<tr key={teacher.teacher_id}>
<td>{index + 1}</td>
<td>{teacher.firstname ?? '—'}</td>
<td>{teacher.lastname ?? '—'}</td>
<td>{teacher.email ?? '—'}</td>
<td>{teacher.cellphone ?? '—'}</td>
<td>{teacher.role ? teacher.role.replace(/_/g, ' ') : '—'}</td>
<td>
{[...(teacher.main_assignments ?? []), ...(teacher.ta_assignments ?? [])].length === 0 ? <span className="text-muted">No classes assigned</span> : (
<ul className="list-unstyled mb-0">
{(teacher.main_assignments ?? []).map((assignment) => (
<li key={`main-${teacher.teacher_id}-${assignment.section_id}`} className="mb-1 d-flex align-items-center justify-content-between gap-2">
<span><strong>{assignment.class_section_name ?? 'Class'}</strong> (Main)</span>
<button className="btn btn-outline-danger btn-sm" type="button" onClick={() => void removeAssignment(teacher.teacher_id, Number(assignment.section_id), 'main')}>Remove</button>
</li>
))}
{(teacher.ta_assignments ?? []).map((assignment) => (
<li key={`ta-${teacher.teacher_id}-${assignment.section_id}`} className="mb-1 d-flex align-items-center justify-content-between gap-2">
<span><strong>{assignment.class_section_name ?? 'Class'}</strong> (TA)</span>
<button className="btn btn-outline-danger btn-sm" type="button" onClick={() => void removeAssignment(teacher.teacher_id, Number(assignment.section_id), 'ta')}>Remove</button>
</li>
))}
</ul>
)}
</td>
<td>{teacher.school_year ?? schoolYear ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
export function AdminTeacherSubmissionsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
const [selected, setSelected] = useState<Record<string, boolean>>({})
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [semester, setSemester] = useState<string | null>(null)
const [schoolYear, setSchoolYear] = useState<string | null>(null)
async function load() {
setLoading(true)
setError(null)
try {
const data = await fetchTeacherSubmissions(searchParams)
setRows(data.rows ?? [])
setSummary(data.summary ?? {})
setSemester(data.semester ?? null)
setSchoolYear(data.schoolYear ?? null)
} catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load teacher submissions.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [searchParams])
async function sendNotifications() {
const notify = Object.entries(selected).filter(([, checked]) => checked).map(([id]) => Number(id))
if (notify.length === 0) return
const missingItems = Object.fromEntries(
rows.flatMap((row) =>
(row.teachers ?? []).map((teacher) => [String(teacher.id), row.missing_items ?? []]),
),
)
try {
const result = await notifyTeacherSubmissions({ notify, missing_items: missingItems })
setMessage(result.message ?? 'Notifications sent.')
setError(null)
} catch (e: unknown) {
setMessage(null)
setError(e instanceof ApiHttpError ? e.message : 'Unable to send notifications.')
}
}
return (
<AdminParityShell title="Teacher Submission Dashboard">
{message ? <div className="alert alert-info">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
<CiAcademicFilter
selectedYear={searchParams.get('school_year') ?? schoolYear}
selectedSemester={searchParams.get('semester') ?? semester}
onApply={({ schoolYear: year, semester: sem }) => {
const next = new URLSearchParams(searchParams)
if (year) next.set('school_year', year)
else next.delete('school_year')
if (sem) next.set('semester', sem)
else next.delete('semester')
setSearchParams(next)
}}
/>
<div className="row g-3 mb-4">
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Completion</div><div className="fs-3 fw-semibold">{summary.submission_percentage ?? 0}%</div></div></div></div>
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Missing Items</div><div className="fs-3 fw-semibold">{summary.missing_items ?? 0}</div></div></div></div>
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Submitted</div><div className="fs-3 fw-semibold">{summary.submitted_items ?? 0}</div></div></div></div>
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Term</div><div className="fs-6 fw-semibold">{`${semester ?? '—'} ${schoolYear ?? ''}`.trim()}</div></div></div></div>
</div>
<div className="d-flex justify-content-end mb-3">
<button
className="btn btn-primary"
type="button"
disabled={loading}
onClick={() => void sendNotifications()}
>
Notify Selected Teachers
</button>
</div>
<div className="table-responsive">
{loading ? <p className="text-muted">Loading</p> : null}
<table className="table table-striped table-bordered align-middle">
<thead className="table-light">
<tr><th>Notify</th><th>Class-Section</th><th>Teachers</th><th>Midterm Score</th><th>Midterm Comment</th><th>Participation</th><th>PTAP Comment</th><th>Attendance</th><th>Missing Items</th><th>Student Count</th></tr>
</thead>
<tbody>
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={10} className="text-muted">
No teacher-class assignments found for this term.
</td>
</tr>
) : null}
{!loading
? rows.map((row) => (
<tr key={String(row.class_section_id ?? row.class_section ?? '')}>
<td>
<div className="d-flex flex-column gap-1">
{(row.teachers ?? []).map((teacher) => (
<label key={teacher.id} className="form-check mb-0">
<input className="form-check-input" type="checkbox" checked={Boolean(selected[String(teacher.id)])} onChange={(e) => setSelected((current) => ({ ...current, [String(teacher.id)]: e.target.checked }))} />
<span className="form-check-label ms-2">{teacher.label}</span>
</label>
))}
</div>
</td>
<td>{row.class_section ?? '—'}</td>
<td>{(row.teachers ?? []).map((teacher) => teacher.label).join(', ') || '—'}</td>
<td><span className={`badge ${submissionBadgeClass(row.midterm_score_status)}`}>{submissionBadgeLabel(row.midterm_score_status)}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.midterm_comment_status)}`}>{submissionBadgeLabel(row.midterm_comment_status)}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.participation_status)}`}>{submissionBadgeLabel(row.participation_status)}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.ptap_comment_status)}`}>{submissionBadgeLabel(row.ptap_comment_status)}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.attendance_status)}`}>{submissionBadgeLabel(row.attendance_status)}</span></td>
<td>{(row.missing_items ?? []).join(', ') || '—'}</td>
<td>{row.student_count ?? 0}</td>
</tr>
))
: null}
</tbody>
</table>
</div>
</AdminParityShell>
)
}
export function AdminClassWarningModalPage() {
const [searchParams] = useSearchParams()
const warningMessage = searchParams.get('message') ?? 'An unexpected class assignment issue occurred.'
return (
<AdminParityShell title="Warning">
<div className="modal d-block position-static" role="alertdialog" aria-modal="true">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content border-warning">
<div className="modal-header bg-warning text-dark">
<h5 className="modal-title">
<i className="bi bi-exclamation-triangle-fill me-2" aria-hidden />Warning
</h5>
</div>
<div className="modal-body text-dark fs-6">{warningMessage}</div>
<div className="modal-footer justify-content-between">
<Link to="/app/administrator/class_assignment" className="btn btn-secondary">
Back to Class Assignment
</Link>
</div>
</div>
</div>
</div>
</AdminParityShell>
)
}
export function AdminCourseMaterialsPage() {
return (
<AdminParityShell title="Course Materials">
<div className="alert alert-secondary">
The legacy `course_materials.php` view only contained hardcoded sample rows, and there is no
matching course-materials API in the current backend.
</div>
<div className="card">
<div className="card-body">
<p className="mb-3">
I converted this page into an explicit admin placeholder instead of wiring a fake upload
form with no backend. The current API-backed alternatives in this app are class progress,
assignments, and class sections.
</p>
<div className="d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-primary" to="/app/administrator/class_assignment">
Class Assignment
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/class_section">
Class Sections
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/events">
Calendar Events
</Link>
</div>
</div>
</div>
</AdminParityShell>
)
}