Files
alrahma_web_client/src/pages/AdministratorToolPages.tsx
T
2026-04-23 15:28:56 -04:00

3322 lines
137 KiB
TypeScript

import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import {
assignTeacherClass,
assignStudentClass,
autoDistributeStudents,
banIp,
createSubjectCurriculum,
createAdministratorCalendarEvent,
createClassSection,
deleteSubjectCurriculum,
deleteAdministratorCalendarEvent,
deleteClassSection,
deleteLateSlipLog,
deleteTeacherClassAssignment,
deleteUser,
fetchAdministratorCalendarEvent,
fetchAdministratorCalendarEvents,
fetchAdministratorDailyAttendance,
fetchBroadcastEmailOptions,
fetchClassSections,
fetchExamDraftAdminData,
fetchFamilyAdminIndex,
fetchGradingOverview,
fetchIpBans,
fetchLateSlipLogs,
fetchNotificationAlerts,
fetchPaypalTransactions,
fetchPaypalTransactionsCsv,
fetchPrintNotificationRecipients,
fetchSchoolCalendarOptions,
fetchRemovedStudents,
fetchStudentDirectory,
fetchStudentEmergencyContacts,
fetchStudentParent,
fetchStudentPromotionTotals,
fetchSubjectCurriculum,
fetchStudentAssignments,
fetchTeacherClassAssignments,
fetchTeacherSubmissions,
fetchUsers,
notifyTeacherSubmissions,
removeStudentClass,
reviewExamDraft,
saveNotificationAlerts,
savePrintNotificationRecipients,
searchAdministratorDashboard,
sendBroadcastEmail,
setStudentActive,
unbanIp,
uploadLegacyExamDraft,
updateAdministratorCalendarEvent,
updateClassSection,
updateStudentProfile,
updateSubjectCurriculum,
} from '../api/session'
import type {
AdministratorDashboardSearchResponse,
BroadcastEmailParentOption,
ClassSectionRow,
ExamDraftRow,
GradingOverviewStudentRow,
NotificationAdminRow,
RemovedStudentRow,
SchoolCalendarDetailRow,
SchoolCalendarEventRow,
StudentDirectoryRow,
StudentAssignmentOption,
StudentAssignmentRow,
StudentPromotionTotalsRow,
SubjectCurriculumEntryRow,
TeacherClassAssignmentRow,
TeacherClassOption,
TeacherSubmissionReportRow,
UserListRow,
} from '../api/types'
import { apiUrl } from '../lib/apiOrigin'
function AdminParityShell({
title,
children,
}: {
title: string
children: ReactNode
}) {
return (
<div className="container-fluid py-3">
<div className="mb-4">
<h2 className="h4 mb-1">{title}</h2>
</div>
{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 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 }>
}>
dates: string[]
}
function buildAttendanceSections(data: Awaited<ReturnType<typeof fetchAdministratorDailyAttendance>>): AttendanceSectionSnapshot[] {
const grades = data.grades ?? {}
const studentsBySection = data.students_by_section ?? {}
const attendanceData = data.attendance_data ?? {}
const attendanceRecord = data.attendance_record ?? {}
const datesBySection = data.dates_by_section ?? {}
const sections: AttendanceSectionSnapshot[] = []
for (const [classIdKey, sectionRows] of Object.entries(grades)) {
for (const row of sectionRows ?? []) {
const sectionId = String(row.class_section_id ?? row.id ?? '')
if (!sectionId) continue
const students = (studentsBySection[sectionId] ?? []).map((student) => {
const studentId = Number(student.id)
const entries = attendanceData[sectionId]?.[String(studentId)] ?? []
const summary = attendanceRecord[sectionId]?.[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,
},
]),
),
}
})
if (students.length === 0) continue
sections.push({
sectionId,
classId: Number(classIdKey),
className: row.class_section_name ?? `Section ${sectionId}`,
students,
dates: datesBySection[sectionId] ?? [],
})
}
}
return sections.sort((a, b) => {
if (a.classId !== b.classId) return a.classId - b.classId
return a.className.localeCompare(b.className)
})
}
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/calendar_view')}>
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/calendar_view')
}}
/>
)
}
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/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 semester = searchParams.get('semester') ?? ''
async function load() {
setLoading(true)
try {
const data = await fetchAdministratorCalendarEvents({
schoolYear: schoolYear || null,
semester: 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, semester])
async function onDelete(eventId: number) {
if (!window.confirm('Delete this event?')) return
await deleteAdministratorCalendarEvent(eventId)
await load()
}
return (
<AdminParityShell title="School Calendar">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex gap-2 mb-3 justify-content-end">
<Link to={`/app/administrator/calendar_add_event${schoolYear || semester ? `?${new URLSearchParams({ ...(schoolYear ? { school_year: schoolYear } : {}), ...(semester ? { semester } : {}) }).toString()}` : ''}`} 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 formData = new FormData(event.currentTarget)
const next = new URLSearchParams()
const year = String(formData.get('school_year') ?? '')
const sem = String(formData.get('semester') ?? '')
if (year) next.set('school_year', year)
if (sem) next.set('semester', sem)
setSearchParams(next)
}}
>
<div>
<label className="form-label">School Year</label>
<input name="school_year" className="form-control" defaultValue={schoolYear} />
</div>
<div>
<label className="form-label">Semester</label>
<select name="semester" className="form-select" defaultValue={semester}>
<option value=""></option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="d-flex gap-2">
<button className="btn btn-secondary" type="submit">Apply</button>
<button className="btn btn-outline-secondary" type="button" onClick={() => setSearchParams(new URLSearchParams())}>
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/calendar_view" className="btn btn-outline-secondary">
Manage Events
</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() {
const [assignments, setAssignments] = useState<StudentAssignmentRow[]>([])
const [classes, setClasses] = useState<StudentAssignmentOption[]>([])
const [schoolYears, setSchoolYears] = useState<string[]>([])
const [selectedYear, setSelectedYear] = useState('')
const [selectedClassByStudent, setSelectedClassByStudent] = useState<Record<number, number>>({})
const [loading, setLoading] = useState(true)
const [savingStudentId, setSavingStudentId] = useState<number | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function load(year?: string | null) {
setLoading(true)
try {
const data = await fetchStudentAssignments(year ?? null)
setAssignments(data.students ?? [])
setClasses(data.classes ?? [])
setSchoolYears(data.schoolYears ?? [])
setSelectedYear(data.selectedYear ?? '')
setSelectedClassByStudent(
Object.fromEntries(
(data.students ?? []).map((row) => [
row.student_id,
Number(row.class_section_ids?.[0] ?? 0),
]),
),
)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load class assignments.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load(null)
}, [])
async function saveStudent(studentId: number) {
const classSectionId = selectedClassByStudent[studentId]
if (!classSectionId) return
setSavingStudentId(studentId)
setMessage(null)
setError(null)
try {
const result = await assignStudentClass({
student_id: studentId,
class_section_ids: [classSectionId],
})
if (!result.ok) {
setError(result.message ?? 'Unable to assign class.')
} else {
setMessage('Class assignment updated.')
await load(selectedYear)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to assign class.')
} finally {
setSavingStudentId(null)
}
}
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}
onChange={(event) => setSelectedYear(event.target.value)}
>
{schoolYears.map((year) => (
<option key={year} value={year}>
{year}
</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>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th>Student</th>
<th>Age</th>
<th>Registration Grade</th>
<th>Current Class</th>
<th>Assign Class</th>
<th>Contact</th>
<th>Save</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-muted">Loading assignments</td></tr>
) : assignments.length === 0 ? (
<tr><td colSpan={7} className="text-muted">No students found.</td></tr>
) : (
assignments.map((row) => (
<tr key={row.student_id}>
<td>{row.name}</td>
<td>{row.age ?? '—'}</td>
<td>{row.registration_grade ?? '—'}</td>
<td>{row.class_section_name ?? 'No class assigned'}</td>
<td>
<select
className="form-select form-select-sm"
value={selectedClassByStudent[row.student_id] ?? 0}
onChange={(event) =>
setSelectedClassByStudent((current) => ({
...current,
[row.student_id]: Number(event.target.value),
}))
}
>
<option value={0}>Select class</option>
{classes.map((section) => (
<option key={section.id} value={section.class_section_id}>
{section.class_section_name}
</option>
))}
</select>
</td>
<td>
<div>{row.email || '—'}</div>
<div className="small text-muted">{row.phone || '—'}</div>
</td>
<td>
<button
className="btn btn-primary btn-sm"
type="button"
disabled={!selectedClassByStudent[row.student_id] || savingStudentId === row.student_id}
onClick={() => void saveStudent(row.student_id)}
>
{savingStudentId === row.student_id ? 'Saving…' : 'Assign'}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</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 [data, setData] = useState<Awaited<ReturnType<typeof fetchAdministratorDailyAttendance>> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeSectionId, setActiveSectionId] = useState<string>('')
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const result = await fetchAdministratorDailyAttendance()
if (cancelled) return
setData(result)
const sections = buildAttendanceSections(result)
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
}
}, [])
const sections = useMemo(() => (data ? buildAttendanceSections(data) : []), [data])
const activeSection = sections.find((row) => row.sectionId === activeSectionId) ?? sections[0]
const visibleDates = activeSection?.dates.slice(-8) ?? []
return (
<AdminParityShell title="Attendance Management">
<div className="d-flex justify-content-end mb-3">
<Link
className="btn btn-outline-primary"
to="/app/administrator/daily_attendance_analysis"
>
Attendance Analysis
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? (
<p className="text-muted">Loading attendance</p>
) : sections.length === 0 ? (
<p className="text-muted">No attendance sections found.</p>
) : (
<>
<ul className="nav nav-tabs flex-wrap mb-3">
{sections.map((section) => (
<li key={section.sectionId} className="nav-item">
<button
type="button"
className={`nav-link ${section.sectionId === activeSection?.sectionId ? 'active' : ''}`}
onClick={() => setActiveSectionId(section.sectionId)}
>
{section.className}
</button>
</li>
))}
</ul>
{activeSection ? (
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead>
<tr>
<th>School ID</th>
<th>Student Name</th>
{visibleDates.map((date) => (
<th key={date}>{formatDate(date)}</th>
))}
<th>Present</th>
<th>Late</th>
<th>Absent</th>
</tr>
</thead>
<tbody>
{activeSection.students.map((student) => (
<tr key={student.id}>
<td>{student.schoolId}</td>
<td>{student.name}</td>
{visibleDates.map((date) => {
const entry = student.entriesByDate[date]
return (
<td key={date}>
<span className={`badge text-bg-${
entry?.status === 'present'
? 'success'
: entry?.status === 'late'
? 'warning'
: entry?.status === 'absent'
? 'danger'
: 'secondary'
}`}>
{entry?.status ? entry.status : '—'}
</span>
</td>
)
})}
<td>{student.summary.present}</td>
<td>{student.summary.late}</td>
<td>{student.summary.absent}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</>
)}
</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 [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
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() {
setLoading(true)
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.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function submitLegacy(event: FormEvent) {
event.preventDefault()
if (!legacyFile) return
try {
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 teacherFileUrl = (name?: string | null) => (name ? apiUrl(`/api/v1/files/exams/teacher/${encodeURIComponent(name)}`) : null)
const finalFileUrl = (name?: string | null) => (name ? apiUrl(`/api/v1/files/exams/final/${encodeURIComponent(name)}`) : null)
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="card mb-4">
<div className="card-header">Upload Legacy Exam</div>
<div className="card-body">
<form className="row g-3" onSubmit={submitLegacy}>
<div className="col-md-3">
<label className="form-label">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>
{(data?.class_sections ?? []).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">School Year</label>
<input className="form-control" value={legacyForm.school_year} onChange={(event) => setLegacyForm((current) => ({ ...current, school_year: event.target.value }))} required />
</div>
<div className="col-md-2">
<label className="form-label">Semester</label>
<input className="form-control" value={legacyForm.semester} onChange={(event) => setLegacyForm((current) => ({ ...current, semester: event.target.value }))} required />
</div>
<div className="col-md-2">
<label className="form-label">Exam Type</label>
<select className="form-select" value={legacyForm.exam_type} onChange={(event) => setLegacyForm((current) => ({ ...current, exam_type: event.target.value }))}>
<option value="">Legacy Exam</option>
{(data?.exam_types ?? []).map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">File</label>
<input className="form-control" type="file" accept={(data?.allowed_extensions ?? ['doc','docx','pdf']).map((ext) => `.${ext}`).join(',')} onChange={(event) => setLegacyFile(event.target.files?.[0] ?? null)} required />
</div>
<div className="col-12 d-flex justify-content-end">
<button className="btn btn-primary" type="submit" disabled={!legacyFile}>Upload</button>
</div>
</form>
</div>
</div>
<div className="card">
<div className="card-header">Submissions</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th>Teacher</th>
<th>Class</th>
<th>Title</th>
<th>Type</th>
<th>Version</th>
<th>Status</th>
<th>Files</th>
<th>Review</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} className="text-muted">Loading drafts</td></tr>
) : (data?.drafts ?? []).length === 0 ? (
<tr><td colSpan={8} className="text-muted">No exam drafts found.</td></tr>
) : (
(data?.drafts ?? []).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 }
return (
<tr key={draft.id}>
<td>{teacherName}</td>
<td>{draft.class_section_name ?? '—'}</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 text-bg-secondary">{draft.status ?? '—'}</span></td>
<td>
<div className="d-flex flex-column gap-1">
{teacherFileUrl(draft.teacher_file) ? <a href={teacherFileUrl(draft.teacher_file)!} target="_blank" rel="noreferrer">Teacher file</a> : null}
{finalFileUrl(draft.final_pdf_file ?? draft.final_file) ? <a href={finalFileUrl(draft.final_pdf_file ?? draft.final_file)!} target="_blank" rel="noreferrer">Final file</a> : null}
</div>
</td>
<td style={{ minWidth: 260 }}>
<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="Comments" />
<input className="form-control form-control-sm" type="file" accept={(data?.allowed_extensions ?? ['doc','docx','pdf']).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>
</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)
async function load(currentStatus = status) {
setLoading(true)
try {
const data = await fetchIpBans({ status: currentStatus, perPage: 100 })
setRows((data.data?.bans as Array<Record<string, unknown>>) ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load IP bans.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load(status)
}, [status])
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">
<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')}>
<option value="all">All entries</option>
<option value="active">Active bans only</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>
<th>ID</th>
<th>IP Address</th>
<th>Attempts</th>
<th>Last Attempt</th>
<th>Blocked Until</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} className="text-muted">Loading IP bans</td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={9} className="text-muted">No IP ban records found.</td></tr>
) : rows.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>
</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((r) => String(r.name ?? '')).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 Error ? e.message : 'Unable to load parent profiles.')
} finally {
setLoading(false)
}
})()
}, [])
async function loadGuardian(guardianId: number) {
setSelectedGuardianId(guardianId)
const data = await fetchFamilyAdminIndex({ guardianId })
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
}
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?: string | null) {
const normalized = String(status ?? '').toLowerCase()
if (normalized === 'submitted') return 'bg-success'
if (normalized === 'partial') return 'bg-warning text-dark'
return 'bg-secondary'
}
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}
<form className="row g-2 align-items-end mb-3" onSubmit={(e) => { e.preventDefault(); void load() }}>
<div className="col-sm-4 col-lg-3">
<label className="form-label">School Year</label>
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} placeholder="2025-2026" />
</div>
<div className="col-auto">
<button className="btn btn-primary" type="submit">Apply</button>
</div>
</form>
<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>
)
}
export function AdminSectionsAutoDistributePage() {
const [schoolYear, setSchoolYear] = useState('')
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-sm-4 col-lg-3">
<label className="form-label">School Year</label>
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} />
</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-outline-secondary" type="button" onClick={() => void load()}>Refresh Totals</button></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 [schoolYear, setSchoolYear] = useState('')
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)
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()
}
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">
<label className="form-label">School Year</label>
<div className="d-flex gap-2">
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} />
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button>
</div>
</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="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead className="table-dark">
<tr><th>Student Name</th><th>Age</th><th>Registration Grade</th><th>New Student</th><th>Assigned Classes</th><th>Registration Date</th><th>School Year</th></tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={7} className="text-muted">Loading students</td></tr> : students.length === 0 ? <tr><td colSpan={7} className="text-muted">No students found.</td></tr> : students.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>
{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 [schoolYear, setSchoolYear] = useState('')
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)
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()
}
return (
<AdminParityShell title="Student Profiles">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="d-flex gap-2 mb-3">
<input className="form-control" style={{ maxWidth: 240 }} value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} placeholder="School year" />
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button>
</div>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead className="table-dark">
<tr><th>School ID</th><th>First Name</th><th>Last Name</th><th>DOB</th><th>Age</th><th>Gender</th><th>Assigned Grade</th><th>Active</th><th>Photo Consent</th><th>Registration Date</th><th>Action</th></tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={11} className="text-muted">Loading students</td></tr> : students.length === 0 ? <tr><td colSpan={11} className="text-muted">No students found.</td></tr> : students.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 [classes, setClasses] = useState<Array<{ id: number; class_name: string }>>([])
const [entries, setEntries] = useState<SubjectCurriculumEntryRow[]>([])
const [subjects, setSubjects] = useState<Record<string, string>>({})
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)
async function load() {
const data = await fetchSubjectCurriculum()
setClasses(data.classes ?? [])
setEntries(data.entries ?? [])
setSubjects(data.subject_labels ?? {})
}
useEffect(() => {
void load()
}, [])
async function submitForm(e: FormEvent) {
e.preventDefault()
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,
}
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()
}
return (
<AdminParityShell title="Subject Curriculum">
{message ? <div className="alert alert-info">{message}</div> : null}
<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)}>
<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">{editingId ? 'Apply changes' : 'Save curriculum entry'}</button>
{editingId ? <button className="btn btn-outline-secondary" type="button" onClick={() => { setEditingId(null); setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' }) }}>Cancel</button> : null}
</div>
</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>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead>
<tbody>
{entries.length === 0 ? <tr><td colSpan={6} className="text-muted">No curriculum records yet.</td></tr> : entries.map((entry) => (
<tr key={entry.id}>
<td>{entry.class_name ?? '—'}</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" 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" onClick={() => void deleteSubjectCurriculum(entry.id).then(load)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminParityShell>
)
}
export function AdminTeacherClassAssignmentPage() {
const [schoolYear, setSchoolYear] = useState('')
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"><label className="form-label">School Year</label><div className="d-flex gap-2"><input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} /><button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button></div></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 [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 [semester, setSemester] = useState<string | null>(null)
const [schoolYear, setSchoolYear] = useState<string | null>(null)
async function load() {
const data = await fetchTeacherSubmissions()
setRows(data.rows ?? [])
setSummary(data.summary ?? {})
setSemester(data.semester ?? null)
setSchoolYear(data.schoolYear ?? null)
}
useEffect(() => {
void load()
}, [])
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 ?? []]),
),
)
const result = await notifyTeacherSubmissions({ notify, missing_items: missingItems })
setMessage(result.message ?? 'Notifications sent.')
}
return (
<AdminParityShell title="Teacher Submission Dashboard">
{message ? <div className="alert alert-info">{message}</div> : null}
<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" onClick={() => void sendNotifications()}>Notify Selected Teachers</button>
</div>
<div className="table-responsive">
<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>
{rows.length === 0 ? <tr><td colSpan={10} className="text-muted">No teacher-class assignments found for this term.</td></tr> : 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)}`}>{row.midterm_score_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.midterm_comment_status)}`}>{row.midterm_comment_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.participation_status)}`}>{row.participation_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.ptap_comment_status)}`}>{row.ptap_comment_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.attendance_status)}`}>{row.attendance_status ?? 'missing'}</span></td>
<td>{(row.missing_items ?? []).join(', ') || '—'}</td>
<td>{row.student_count ?? 0}</td>
</tr>
))}
</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/calendar_view">
Calendar Events
</Link>
</div>
</div>
</div>
</AdminParityShell>
)
}