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