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 (
{showTitle ? (

{title}

) : null} {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 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 ( onSort(col)}> {label}{' '} {active && sortDir === 'desc' ? '▼' : '▲'} ) } 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 = '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[] } type AttendanceRenderedStudentRow = { sectionId: string sectionName: string student: AttendanceSectionSnapshot['students'][number] } type AttendanceGradeGroup = { key: string label: string order: number sections: AttendanceSectionSnapshot[] } function recordPick(map: Record | 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>): AttendanceSectionSnapshot[] { const raw = data as unknown as Record const grades = (data.grades ?? raw.Grades ?? raw.grade_sections ?? raw.sections) as | Record | 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 > const attendanceRecord = (data.attendance_record ?? raw.attendanceRecord ?? {}) as Record< string, Record > const datesBySection = (data.dates_by_section ?? raw.datesBySection ?? {}) as Record 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() 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() 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} />