3322 lines
137 KiB
TypeScript
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>
|
|
)
|
|
}
|