import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { assignTeacherClass, assignStudentClass, autoDistributeStudents, banIp, createSubjectCurriculum, createAdministratorCalendarEvent, createClassSection, deleteSubjectCurriculum, deleteAdministratorCalendarEvent, deleteClassSection, deleteLateSlipLog, deleteTeacherClassAssignment, deleteUser, fetchAdministratorCalendarEvent, fetchAdministratorCalendarEvents, fetchAdministratorDailyAttendance, fetchBroadcastEmailOptions, fetchClassSections, fetchExamDraftAdminData, fetchFamilyAdminIndex, fetchGradingOverview, fetchIpBans, fetchLateSlipLogs, fetchNotificationAlerts, fetchPaypalTransactions, fetchPaypalTransactionsCsv, fetchPrintNotificationRecipients, fetchSchoolCalendarOptions, fetchRemovedStudents, fetchStudentDirectory, fetchStudentEmergencyContacts, fetchStudentParent, fetchStudentPromotionTotals, fetchSubjectCurriculum, fetchStudentAssignments, fetchTeacherClassAssignments, fetchTeacherSubmissions, fetchUsers, notifyTeacherSubmissions, removeStudentClass, reviewExamDraft, saveNotificationAlerts, savePrintNotificationRecipients, searchAdministratorDashboard, sendBroadcastEmail, setStudentActive, unbanIp, uploadLegacyExamDraft, updateAdministratorCalendarEvent, updateClassSection, updateStudentProfile, updateSubjectCurriculum, } from '../api/session' import type { AdministratorDashboardSearchResponse, BroadcastEmailParentOption, ClassSectionRow, ExamDraftRow, GradingOverviewStudentRow, NotificationAdminRow, RemovedStudentRow, SchoolCalendarDetailRow, SchoolCalendarEventRow, StudentDirectoryRow, StudentAssignmentOption, StudentAssignmentRow, StudentPromotionTotalsRow, SubjectCurriculumEntryRow, TeacherClassAssignmentRow, TeacherClassOption, TeacherSubmissionReportRow, UserListRow, } from '../api/types' import { apiUrl } from '../lib/apiOrigin' function AdminParityShell({ title, children, }: { title: string children: ReactNode }) { return (

{title}

{children}
) } function formatDate(value?: string | null) { if (!value) return '—' const date = new Date(value) return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString() } function formatDateInput(value?: string | null) { return value ? String(value).slice(0, 10) : '' } function monthLabel(value: string) { const date = new Date(`${value}T00:00:00`) if (Number.isNaN(date.getTime())) return value return date.toLocaleString(undefined, { month: 'long', year: 'numeric' }) } function eventAudienceBadges(row: SchoolCalendarDetailRow | SchoolCalendarEventRow) { const props: Record = '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 }> dates: string[] } function buildAttendanceSections(data: Awaited>): AttendanceSectionSnapshot[] { const grades = data.grades ?? {} const studentsBySection = data.students_by_section ?? {} const attendanceData = data.attendance_data ?? {} const attendanceRecord = data.attendance_record ?? {} const datesBySection = data.dates_by_section ?? {} const sections: AttendanceSectionSnapshot[] = [] for (const [classIdKey, sectionRows] of Object.entries(grades)) { for (const row of sectionRows ?? []) { const sectionId = String(row.class_section_id ?? row.id ?? '') if (!sectionId) continue const students = (studentsBySection[sectionId] ?? []).map((student) => { const studentId = Number(student.id) const entries = attendanceData[sectionId]?.[String(studentId)] ?? [] const summary = attendanceRecord[sectionId]?.[String(studentId)] ?? {} return { id: studentId, schoolId: student.school_id ?? '—', name: `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim() || `Student ${studentId}`, summary: { present: Number(summary.total_presence ?? 0), late: Number(summary.total_late ?? 0), absent: Number(summary.total_absence ?? 0), total: Number(summary.total_attendance ?? 0), }, entriesByDate: Object.fromEntries( entries.map((entry) => [ String(entry.date ?? ''), { status: String(entry.status ?? ''), reason: entry.reason ?? null, }, ]), ), } }) if (students.length === 0) continue sections.push({ sectionId, classId: Number(classIdKey), className: row.class_section_name ?? `Section ${sectionId}`, students, dates: datesBySection[sectionId] ?? [], }) } } return sections.sort((a, b) => { if (a.classId !== b.classId) return a.classId - b.classId return a.className.localeCompare(b.className) }) } function buildAttendanceAnalysis(sections: AttendanceSectionSnapshot[]) { const overall = { present: 0, late: 0, absent: 0 } const byClass = new Map() 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([]) const [fromOptions, setFromOptions] = useState<{ key: string; label: string }[]>([]) const [selectedIds, setSelectedIds] = useState([]) const [filter, setFilter] = useState('') const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [message, setMessage] = useState(null) const [error, setError] = useState(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 ( {message ?
{message}
: null} {error ?
{error}
: null}
Email Details
setForm((current) => ({ ...current, subject: event.target.value }))} disabled={submitting} />
setForm((current) => ({ ...current, wrap_layout: event.target.checked })) } disabled={submitting} />