diff --git a/src/App.tsx b/src/App.tsx index 7b416fc..c0409e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,186 @@ const CiPlaceholderPage = lazy(() => const NavBuilderPage = lazy(() => import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })), ) +const AdminBroadcastEmailPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ default: m.AdminBroadcastEmailPage })), +) +const AdminCalendarPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ default: m.AdminCalendarPage })), +) +const AdminCalendarViewPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ default: m.AdminCalendarViewPage })), +) +const AdminCalendarAddEventPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminCalendarAddEventPage, + })), +) +const AdminCalendarEditPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ default: m.AdminCalendarEditPage })), +) +const AdminClassAssignmentPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminClassAssignmentPage, + })), +) +const AdminClassSectionPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ default: m.AdminClassSectionPage })), +) +const AdminClassWarningModalPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminClassWarningModalPage, + })), +) +const AdminCourseMaterialsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminCourseMaterialsPage, + })), +) +const AdminDailyAttendancePage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminDailyAttendancePage, + })), +) +const AdminDailyAttendanceAnalysisPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminDailyAttendanceAnalysisPage, + })), +) +const AdminExamDraftsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminExamDraftsPage, + })), +) +const AdminGradingManagementPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminGradingManagementPage, + })), +) +const AdminIpBansPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminIpBansPage, + })), +) +const AdminLateSlipLogsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminLateSlipLogsPage, + })), +) +const AdminManageUsersPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminManageUsersPage, + })), +) +const AdminNotificationsAlertsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminNotificationsAlertsPage, + })), +) +const AdminParentProfilePage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminParentProfilePage, + })), +) +const AdminPaypalTransactionsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminPaypalTransactionsPage, + })), +) +const AdminPrintNotificationAdminsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminPrintNotificationAdminsPage, + })), +) +const AdminRemovedStudentsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminRemovedStudentsPage, + })), +) +const AdminSearchResultsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminSearchResultsPage, + })), +) +const AdminSectionsAutoDistributePage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminSectionsAutoDistributePage, + })), +) +const AdminStudentClassAssignmentPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminStudentClassAssignmentPage, + })), +) +const AdminStudentProfilesPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminStudentProfilesPage, + })), +) +const AdminSubjectCurriculumPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminSubjectCurriculumPage, + })), +) +const AdminTeacherClassAssignmentPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminTeacherClassAssignmentPage, + })), +) +const AdminTeacherSubmissionsPage = lazy(() => + import('./pages/AdministratorToolPages').then((m) => ({ + default: m.AdminTeacherSubmissionsPage, + })), +) +const CompetitionWinnersIndexPage = lazy(() => + import('./pages/CompetitionWinnerPages').then((m) => ({ + default: m.CompetitionWinnersIndexPage, + })), +) +const CompetitionWinnerFormPage = lazy(() => + import('./pages/CompetitionWinnerPages').then((m) => ({ + default: m.CompetitionWinnerFormPage, + })), +) +const CompetitionWinnerScoresPage = lazy(() => + import('./pages/CompetitionWinnerPages').then((m) => ({ + default: m.CompetitionWinnerScoresPage, + })), +) +const CompetitionWinnerPreviewPage = lazy(() => + import('./pages/CompetitionWinnerPages').then((m) => ({ + default: m.CompetitionWinnerPreviewPage, + })), +) +const CompetitionWinnerWinnersPage = lazy(() => + import('./pages/CompetitionWinnerPages').then((m) => ({ + default: m.CompetitionWinnerWinnersPage, + })), +) +const AdminClassProgressListPage = lazy(() => + import('./pages/AdminProgressPages').then((m) => ({ + default: m.AdminClassProgressListPage, + })), +) +const AdminClassProgressViewPage = lazy(() => + import('./pages/AdminProgressPages').then((m) => ({ + default: m.AdminClassProgressViewPage, + })), +) +const AdminStudentScoreCardPage = lazy(() => + import('./pages/AdminProgressPages').then((m) => ({ + default: m.AdminStudentScoreCardPage, + })), +) +const AdminEmergencyContactIndexPage = lazy(() => + import('./pages/AdminProgressPages').then((m) => ({ + default: m.AdminEmergencyContactIndexPage, + })), +) +const AdminEmergencyContactEditPage = lazy(() => + import('./pages/AdminProgressPages').then((m) => ({ + default: m.AdminEmergencyContactEditPage, + })), +) const ParentHomePage = lazy(() => import('./pages/parent/ParentHomePage').then((m) => ({ default: m.ParentHomePage })), ) @@ -229,6 +409,48 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/api/session.ts b/src/api/session.ts index 6fa22ee..7189e3d 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -1,11 +1,25 @@ import { apiUrl } from '../lib/apiOrigin' import { apiFetch, getStoredToken } from './http' import type { + AdministratorAbsenceFormResponse, + AdministratorAbsenceSubmitResponse, + AdministratorAdminAttendanceResponse, + AdministratorDailyAttendanceResponse, + AdministratorDashboardMetricsResponse, + AdministratorDashboardSearchResponse, ApiEnvelope, + BroadcastEmailOptionsResponse, + BroadcastEmailSendResponse, + ClassSectionShowResponse, + ClassSectionsResponse, DashboardPayload, + FamilyAdminIndexResponse, + GradingOverviewResponse, + IpBansResponse, LoginFailure, LoginResponse, LoginSuccess, + LateSlipLogsResponse, NavBuilderDataResponse, NavBuilderDeleteResponse, NavBuilderSaveResponse, @@ -19,16 +33,41 @@ import type { ParentRegistrationOverviewResponse, ParentEmergencyContactsResponse, ParentAttendanceReportsListResponse, + SchoolCalendarListResponse, + SchoolCalendarOptionsResponse, + SchoolCalendarShowResponse, ParentAuthorizedUsersApiResponse, ParentMessagesResponse, + NotificationAlertsResponse, + PaypalTransactionsResponse, + PrintNotificationRecipientsResponse, SchoolCalendarEventsResponse, + StudentAssignmentsResponse, StudentScoresResponse, ReportCardAcknowledgementResponse, ReportCardMetaResponse, + RemovedStudentsResponse, ClassProgressGroupsResponse, + ClassProgressMetaResponse, ClassProgressShowResponse, ParentProfileRecord, ContactSubmitResponse, + CompetitionScoresEditResponse, + CompetitionScoresIndexResponse, + ExamDraftAdminResponse, + PublicCompetitionIndexResponse, + PublicCompetitionShowResponse, + StudentDirectoryResponse, + StudentScoreCardResponse, + StudentEmergencyContactResponse, + StudentParentResponse, + StudentPromotionTotalsResponse, + SubjectCurriculumResponse, + TeacherClassAssignmentsResponse, + TeacherSubmissionsResponse, + EmergencyContactGroupsResponse, + EmergencyContactShowResponse, + UsersResponse, } from './types' export async function loginRequest( @@ -57,6 +96,702 @@ export async function fetchDashboardRoute(): Promise>('/api/v1/dashboard/route') } +export async function fetchAdministratorDashboardMetrics(): Promise { + return apiFetch('/api/v1/administrator/dashboard/metrics') +} + +export async function searchAdministratorDashboard(query: string): Promise { + const q = query.trim() + const qs = q ? `?query=${encodeURIComponent(q)}` : '' + return apiFetch(`/api/v1/administrator/dashboard/user-search${qs}`) +} + +export async function fetchAdministratorAbsenceForm(): Promise { + return apiFetch('/api/v1/administrator/absence') +} + +export async function submitAdministratorAbsence(payload: { + dates: string[] + reason_type?: string + reason: string +}): Promise { + return apiFetch('/api/v1/administrator/absence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function fetchAdministratorDailyAttendance( + params?: { semester?: string | null; schoolYear?: string | null }, +): Promise { + const query = new URLSearchParams() + if (params?.semester) query.set('semester', params.semester) + if (params?.schoolYear) query.set('school_year', params.schoolYear) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/attendance/admin/daily${qs}`) +} + +export async function addAdministratorAttendanceEntry(payload: { + class_section_id: number + student_id: number + school_id?: string | null + status: 'present' | 'absent' | 'late' + date?: string | null + reason?: string | null + class_id?: number | null + is_reported?: 'yes' | 'no' | null +}): Promise<{ message?: string }> { + return apiFetch<{ message?: string }>('/api/v1/attendance/admin/add-entry', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function updateAdministratorAttendance(payload: { + class_section_id: number + student_id: number + school_id?: string | null + status: 'present' | 'absent' | 'late' + date: string + reason?: string | null + class_id?: number | null + semester?: string | null + school_year?: string | null + is_reported?: string | null +}): Promise<{ message?: string }> { + return apiFetch<{ message?: string }>('/api/v1/attendance/admin/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function fetchAdministratorAdminAttendance( + params?: { date?: string | null; semester?: string | null; schoolYear?: string | null }, +): Promise { + const query = new URLSearchParams() + if (params?.date) query.set('date', params.date) + if (params?.semester) query.set('semester', params.semester) + if (params?.schoolYear) query.set('school_year', params.schoolYear) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/attendance/staff/admins${qs}`) +} + +export async function saveAdministratorAdminAttendance(payload: { + date: string + semester: string + school_year: string + admins: Record< + string, + { + status?: string | null + reason?: string | null + role_name?: string | null + role_slug?: string | null + } + > +}): Promise<{ message?: string }> { + return apiFetch<{ message?: string }>('/api/v1/attendance/staff/admins/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function fetchBroadcastEmailOptions(): Promise { + return apiFetch('/api/v1/broadcast-email/options') +} + +export async function sendBroadcastEmail(payload: { + from_key: string + mode: 'personalized' | 'standard' + subject: string + body_html: string + wrap_layout?: boolean + preheader?: string + cta_text?: string + cta_url?: string + test_email?: string + send_test_only?: boolean + parent_ids?: number[] +}): Promise { + return apiFetch('/api/v1/broadcast-email/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function fetchSchoolCalendarOptions(): Promise { + return apiFetch('/api/v1/settings/school-calendar/options') +} + +export async function fetchAdministratorCalendarEvents(params?: { + schoolYear?: string | null + semester?: string | null + audience?: 'admin' | 'teacher' | 'parent' | null +}): Promise { + const query = new URLSearchParams() + if (params?.schoolYear) query.set('school_year', params.schoolYear) + if (params?.semester) query.set('semester', params.semester) + if (params?.audience) query.set('audience', params.audience) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/settings/school-calendar/events${qs}`) +} + +export async function fetchAdministratorCalendarEvent(eventId: number): Promise { + return apiFetch(`/api/v1/settings/school-calendar/events/${eventId}`) +} + +export async function createAdministratorCalendarEvent(payload: { + title: string + description?: string + event_type?: string + date: string + notify_parent?: boolean + notify_teacher?: boolean + notify_admin?: boolean + no_school?: boolean + school_year?: string + semester?: string + send_email_parent?: boolean + send_email_teacher?: boolean + send_email_admin?: boolean +}): Promise { + return apiFetch('/api/v1/settings/school-calendar/events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function updateAdministratorCalendarEvent( + eventId: number, + payload: Record, +): Promise { + return apiFetch(`/api/v1/settings/school-calendar/events/${eventId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function deleteAdministratorCalendarEvent(eventId: number): Promise> { + return apiFetch>(`/api/v1/settings/school-calendar/events/${eventId}`, { + method: 'DELETE', + }) +} + +export async function fetchStudentAssignments( + schoolYear?: string | null, +): Promise { + const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : '' + return apiFetch(`/api/v1/students/assignments${query}`) +} + +export async function assignStudentClass(payload: { + student_id: number + class_section_ids?: number[] + class_section_id?: number[] + is_event_only?: boolean +}): Promise<{ ok: boolean; message?: string }> { + return apiFetch<{ ok: boolean; message?: string }>('/api/v1/students/assign-class', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + student_id: payload.student_id, + class_section_id: payload.class_section_id ?? payload.class_section_ids ?? [], + is_event_only: payload.is_event_only ?? false, + }), + }) +} + +export async function removeStudentClass(payload: { + student_id: number + class_section_id: number +}): Promise<{ ok: boolean; message?: string }> { + return apiFetch<{ ok: boolean; message?: string }>('/api/v1/students/remove-class', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function fetchRemovedStudents( + schoolYear?: string | null, +): Promise { + const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : '' + return apiFetch(`/api/v1/students/removed${query}`) +} + +export async function setStudentActive(payload: { + student_id: number + is_active: boolean +}): Promise<{ ok: boolean; message?: string }> { + return apiFetch<{ ok: boolean; message?: string }>('/api/v1/students/set-active', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function autoDistributeStudents(payload: { + class_id?: number + class_section_id?: number + students_per_section: number + school_year?: string | null +}): Promise>; message?: string }>> { + return apiFetch>; message?: string }>>( + '/api/v1/students/auto-distribute', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) +} + +export async function fetchStudentPromotionTotals( + schoolYear?: string | null, +): Promise { + const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : '' + return apiFetch(`/api/v1/students/promotion-totals${query}`) +} + +export async function fetchStudentDirectory( + schoolYear?: string | null, +): Promise { + const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : '' + return apiFetch(`/api/v1/students${query}`) +} + +export async function updateStudentProfile( + studentId: number, + payload: Record, +): Promise<{ ok: boolean; message?: string }> { + return apiFetch<{ ok: boolean; message?: string }>(`/api/v1/students/${studentId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function fetchStudentParent(studentId: number): Promise { + return apiFetch(`/api/v1/students/${studentId}/parents`) +} + +export async function fetchStudentEmergencyContacts( + studentId: number, +): Promise { + return apiFetch(`/api/v1/students/${studentId}/emergency-contacts`) +} + +export async function fetchTeacherClassAssignments( + schoolYear?: string | null, +): Promise { + const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : '' + return apiFetch( + `/api/v1/administrator/teacher-class/assignments${query}`, + ) +} + +export async function assignTeacherClass(payload: { + teacher_id: number + class_section_id: number + teacher_role?: string + school_year?: string +}): Promise<{ ok: boolean; message?: string; position?: string | null }> { + return apiFetch<{ ok: boolean; message?: string; position?: string | null }>( + '/api/v1/administrator/teacher-class/assign', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) +} + +export async function deleteTeacherClassAssignment(payload: { + teacher_id: number + class_section_id: number + position: 'main' | 'ta' + school_year?: string +}): Promise<{ ok: boolean; message?: string }> { + return apiFetch<{ ok: boolean; message?: string }>( + '/api/v1/administrator/teacher-class/delete', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) +} + +export async function fetchTeacherSubmissions(): Promise { + return apiFetch('/api/v1/administrator/teacher-submissions') +} + +export async function fetchPublishedCompetitions(): Promise { + return apiFetch('/api/winners/competitions', {}, { attachAuth: false }) +} + +export async function fetchPublishedCompetition(id: number): Promise { + return apiFetch(`/api/winners/competitions/${id}`, {}, { attachAuth: false }) +} + +export async function fetchCompetitionScoresIndex( + classSectionId?: number | null, +): Promise { + const query = classSectionId ? `?class_section_id=${encodeURIComponent(String(classSectionId))}` : '' + return apiFetch(`/api/v1/competition-scores${query}`) +} + +export async function fetchCompetitionScoresDetail( + id: number, + classSectionId?: number | null, +): Promise { + const query = classSectionId ? `?class_section_id=${encodeURIComponent(String(classSectionId))}` : '' + return apiFetch(`/api/v1/competition-scores/${id}${query}`) +} + +export async function saveCompetitionScores( + id: number, + payload: { class_section_id?: number | null; scores: Record }, +): Promise<{ ok: boolean; message?: string; savedCount?: number; invalidScores?: string[] }> { + return apiFetch<{ ok: boolean; message?: string; savedCount?: number; invalidScores?: string[] }>( + `/api/v1/competition-scores/${id}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) +} + +export async function notifyTeacherSubmissions(payload: { + notify: number[] + missing_items?: Record +}): Promise<{ message?: string; sent?: number; failed?: number }> { + return apiFetch<{ message?: string; sent?: number; failed?: number }>( + '/api/v1/administrator/teacher-submissions/notify', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) +} + +export async function fetchSubjectCurriculum(): Promise { + return apiFetch('/api/v1/subjects/curriculum') +} + +export async function createSubjectCurriculum(payload: { + class_id: number + subject: string + unit_number?: number | null + unit_title?: string | null + chapter_name: string +}): Promise> { + return apiFetch>('/api/v1/subjects/curriculum', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function updateSubjectCurriculum( + id: number, + payload: Record, +): Promise> { + return apiFetch>(`/api/v1/subjects/curriculum/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function deleteSubjectCurriculum(id: number): Promise> { + return apiFetch>(`/api/v1/subjects/curriculum/${id}`, { + method: 'DELETE', + }) +} + +export async function fetchClassSections(params?: { + search?: string + schoolYear?: string | null + semester?: string | null + page?: number + perPage?: number +}): Promise { + const query = new URLSearchParams() + if (params?.search) query.set('search', params.search) + if (params?.schoolYear) query.set('school_year', params.schoolYear) + if (params?.semester) query.set('semester', params.semester) + if (params?.page) query.set('page', String(params.page)) + if (params?.perPage) query.set('per_page', String(params.perPage)) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/class-sections${qs}`) +} + +export async function fetchClassSection(sectionId: number): Promise { + return apiFetch(`/api/v1/class-sections/${sectionId}`) +} + +export async function createClassSection(payload: { + class_id: number + class_section_id: number + class_section_name: string + semester: string + school_year: string +}): Promise { + return apiFetch('/api/v1/class-sections', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function updateClassSection( + sectionId: number, + payload: Partial<{ + class_id: number + class_section_id: number + class_section_name: string + semester: string + school_year: string + }>, +): Promise { + return apiFetch(`/api/v1/class-sections/${sectionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function deleteClassSection(sectionId: number): Promise> { + return apiFetch>(`/api/v1/class-sections/${sectionId}`, { + method: 'DELETE', + }) +} + +export async function fetchExamDraftAdminData(): Promise { + return apiFetch('/api/v1/exams/drafts/admin') +} + +async function fetchMultipartJson(path: string, formData: FormData): Promise { + const headers = new Headers({ Accept: 'application/json' }) + const token = getStoredToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + const res = await fetch(apiUrl(path), { + method: 'POST', + headers, + body: formData, + }) + const body: unknown = await res.json().catch(() => null) + if (!res.ok) { + const msg = + typeof body === 'object' && body !== null && 'message' in body + ? String((body as { message?: unknown }).message ?? '') + : '' + throw new Error(msg || `HTTP ${res.status}`) + } + return body as T +} + +export async function uploadLegacyExamDraft(payload: { + class_section_id: number + school_year: string + semester: string + exam_type?: string + old_exam_file: File +}): Promise<{ ok: boolean; message?: string }> { + const formData = new FormData() + formData.set('class_section_id', String(payload.class_section_id)) + formData.set('school_year', payload.school_year) + formData.set('semester', payload.semester) + if (payload.exam_type) formData.set('exam_type', payload.exam_type) + formData.set('old_exam_file', payload.old_exam_file) + return fetchMultipartJson('/api/v1/exams/drafts/admin/legacy', formData) +} + +export async function reviewExamDraft(payload: { + draft_id: number + review_status?: string + admin_comments?: string + final_file?: File | null +}): Promise<{ ok: boolean; message?: string }> { + const formData = new FormData() + formData.set('draft_id', String(payload.draft_id)) + if (payload.review_status) formData.set('review_status', payload.review_status) + if (payload.admin_comments) formData.set('admin_comments', payload.admin_comments) + if (payload.final_file) formData.set('final_file', payload.final_file) + return fetchMultipartJson('/api/v1/exams/drafts/admin/review', formData) +} + +export async function fetchGradingOverview(params?: { + classId?: number | null + semester?: string | null + schoolYear?: string | null +}): Promise { + const query = new URLSearchParams() + if (params?.classId) query.set('class_id', String(params.classId)) + if (params?.semester) query.set('semester', params.semester) + if (params?.schoolYear) query.set('school_year', params.schoolYear) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/grading/overview${qs}`) +} + +export async function fetchIpBans(params?: { + status?: 'all' | 'active' + page?: number + perPage?: number +}): Promise { + const query = new URLSearchParams() + if (params?.status) query.set('status', params.status) + if (params?.page) query.set('page', String(params.page)) + if (params?.perPage) query.set('per_page', String(params.perPage)) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/ip-bans${qs}`) +} + +export async function banIp(payload: { + id?: number + ip?: string + hours?: number +}): Promise> { + return apiFetch>('/api/v1/ip-bans/ban', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function unbanIp(payload: { + id?: number + ip?: string + all?: boolean +}): Promise> { + return apiFetch>('/api/v1/ip-bans/unban', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function fetchLateSlipLogs(params?: { + schoolYear?: string | null + semester?: string | null + q?: string + dateFrom?: string + dateTo?: string + page?: number + perPage?: number +}): Promise { + const query = new URLSearchParams() + if (params?.schoolYear) query.set('school_year', params.schoolYear) + if (params?.semester) query.set('semester', params.semester) + if (params?.q) query.set('q', params.q) + if (params?.dateFrom) query.set('date_from', params.dateFrom) + if (params?.dateTo) query.set('date_to', params.dateTo) + if (params?.page) query.set('page', String(params.page)) + if (params?.perPage) query.set('per_page', String(params.perPage)) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/attendance/late-slip-logs${qs}`) +} + +export async function deleteLateSlipLog(id: number): Promise> { + return apiFetch>(`/api/v1/attendance/late-slip-logs/${id}`, { + method: 'DELETE', + }) +} + +export async function fetchUsers(params?: { + sort?: string + order?: 'asc' | 'desc' +}): Promise { + const query = new URLSearchParams() + if (params?.sort) query.set('sort', params.sort) + if (params?.order) query.set('order', params.order) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/users${qs}`) +} + +export async function deleteUser(userId: number): Promise<{ ok: boolean; message?: string }> { + return apiFetch<{ ok: boolean; message?: string }>(`/api/v1/users/${userId}`, { + method: 'DELETE', + }) +} + +export async function fetchNotificationAlerts(): Promise { + return apiFetch('/api/v1/administrator/notifications/alerts') +} + +export async function saveNotificationAlerts( + subjects: Record, +): Promise<{ message?: string }> { + return apiFetch<{ message?: string }>('/api/v1/administrator/notifications/alerts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subjects }), + }) +} + +export async function fetchPrintNotificationRecipients(): Promise { + return apiFetch( + '/api/v1/administrator/notifications/print-recipients', + ) +} + +export async function savePrintNotificationRecipients( + notify: Record, +): Promise<{ message?: string }> { + return apiFetch<{ message?: string }>( + '/api/v1/administrator/notifications/print-recipients', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notify }), + }, + ) +} + +export async function fetchFamilyAdminIndex(params?: { + studentId?: number | null + guardianId?: number | null +}): Promise { + const query = new URLSearchParams() + if (params?.studentId) query.set('student_id', String(params.studentId)) + if (params?.guardianId) query.set('guardian_id', String(params.guardianId)) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/family-admin${qs}`) +} + +export async function fetchPaypalTransactions(params?: { + q?: string + perPage?: number +}): Promise { + const query = new URLSearchParams() + if (params?.q) query.set('q', params.q) + if (params?.perPage) query.set('per_page', String(params.perPage)) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/paypal-transactions${qs}`) +} + +export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promise { + const headers = new Headers({ Accept: 'text/csv' }) + const token = getStoredToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + const query = new URLSearchParams() + if (params?.q) query.set('q', params.q) + const suffix = query.size > 0 ? `?${query.toString()}` : '' + const res = await fetch(apiUrl(`/api/v1/paypal-transactions/csv${suffix}`), { headers }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.blob() +} + export async function fetchNavMenu(): Promise { return apiFetch('/api/v1/nav-builder/menu') } @@ -208,14 +943,95 @@ export async function fetchReportCardAcknowledgement( ) } -export async function fetchClassProgressGroups(): Promise { - return apiFetch('/api/v1/class-progress?group_by_week=1') +export async function fetchClassProgressGroups(params?: { + classSectionId?: number | null + status?: string | null + weekStart?: string | null + weekEnd?: string | null + subject?: string | null + page?: number + perPage?: number +}): Promise { + const query = new URLSearchParams() + query.set('group_by_week', '1') + if (params?.classSectionId) query.set('class_section_id', String(params.classSectionId)) + if (params?.status) query.set('status', params.status) + if (params?.weekStart) query.set('week_start', params.weekStart) + if (params?.weekEnd) query.set('week_end', params.weekEnd) + if (params?.subject) query.set('subject', params.subject) + if (params?.page) query.set('page', String(params.page)) + if (params?.perPage) query.set('per_page', String(params.perPage)) + return apiFetch(`/api/v1/class-progress?${query.toString()}`) } export async function fetchClassProgressDetail(id: number): Promise { return apiFetch(`/api/v1/class-progress/${id}`) } +export async function fetchClassProgressMeta(params?: { + classId?: number | null + sundayCount?: number | null +}): Promise { + const query = new URLSearchParams() + if (params?.classId) query.set('class_id', String(params.classId)) + if (params?.sundayCount) query.set('sunday_count', String(params.sundayCount)) + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/class-progress/meta${qs}`) +} + +export async function fetchStudentScoreCard(studentId: number): Promise { + return apiFetch(`/api/v1/students/${studentId}/score-card`) +} + +export async function fetchAdministratorEmergencyContacts(params?: { + parentId?: number | null + parentIds?: number[] +}): Promise { + const query = new URLSearchParams() + if (params?.parentId) query.set('parent_id', String(params.parentId)) + for (const id of params?.parentIds ?? []) { + query.append('parent_ids[]', String(id)) + } + const qs = query.size > 0 ? `?${query.toString()}` : '' + return apiFetch(`/api/v1/administrator/emergency-contacts${qs}`) +} + +export async function fetchAdministratorEmergencyContact( + contactId: number, + parentId: number, +): Promise { + return apiFetch( + `/api/v1/administrator/emergency-contacts/${contactId}?parent_id=${encodeURIComponent(String(parentId))}`, + ) +} + +export async function updateAdministratorEmergencyContact( + contactId: number, + payload: { + parent_id: number + name: string + cellphone: string + email?: string | null + relation?: string | null + }, +): Promise { + return apiFetch(`/api/v1/administrator/emergency-contacts/${contactId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function deleteAdministratorEmergencyContact( + contactId: number, + parentId: number, +): Promise<{ ok: true }> { + return apiFetch<{ ok: true }>( + `/api/v1/administrator/emergency-contacts/${contactId}?parent_id=${encodeURIComponent(String(parentId))}`, + { method: 'DELETE' }, + ) +} + export async function updateParentProfile( payload: Required< Pick< diff --git a/src/api/types.ts b/src/api/types.ts index ce43736..3d9772b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -87,6 +87,120 @@ export type NavBuilderDataResponse = ApiEnvelope<{ data: NavBuilderData }> export type NavBuilderSaveResponse = ApiEnvelope<{ id: number }> export type NavBuilderDeleteResponse = ApiEnvelope +export type AdministratorDashboardMetricsResponse = { + counts?: { + students?: number + teachers?: number + teacherAssistants?: number + admins?: number + parents?: number + } + recentActivities?: Array<{ + login_time?: string | null + email?: string | null + }> + meta?: { + schoolYear?: string | null + semester?: string | null + } +} + +export type AdministratorDashboardSearchResponse = { + query: string + results: { + users?: Record[] + students?: Record[] + parents?: Record[] + staff?: Record[] + emergency_contacts?: Record[] + } + scope_used?: string + scope_label?: string + total_found?: number +} + +export type AdministratorAbsenceRow = { + date?: string | null + status?: string | null + reason?: string | null +} + +export type AdministratorAbsenceFormResponse = { + admin_name?: string | null + semester?: string | null + schoolYear?: string | null + existing?: AdministratorAbsenceRow[] + availableDates?: string[] +} + +export type AdministratorAbsenceSubmitResponse = { + message?: string + saved?: number + dates?: string[] +} + +export type AdministratorDailyAttendanceEntry = { + date?: string | null + status?: string | null + reason?: string | null + is_reported?: string | null + modified_by?: string | number | null +} + +export type AdministratorDailyAttendanceSummary = { + total_presence?: number + total_late?: number + total_absence?: number + total_attendance?: number +} + +export type AdministratorDailyAttendanceStudent = { + id: number + firstname?: string | null + lastname?: string | null + school_id?: string | null +} + +export type AdministratorDailyAttendanceSection = { + id?: number + class_id?: number + class_section_id?: number | string + class_section_name?: string | null +} + +export type AdministratorDailyAttendanceResponse = { + attendance_data?: Record> + attendance_record?: Record> + students_by_section?: Record + grades?: Record + dates_by_section?: Record + date_list?: string[] + no_school_days?: Record + total_passed_days?: number + semester?: string | null + school_year?: string | null + current_admin_name?: string | null +} + +export type AdministratorAdminAttendanceRow = { + user_id?: number + firstname?: string | null + lastname?: string | null + email?: string | null + role_name?: string | null + role_slug?: string | null + status?: string | null + reason?: string | null + date?: string | null +} + +export type AdministratorAdminAttendanceResponse = { + semester?: string | null + school_year?: string | null + date?: string | null + admins_grid?: AdministratorAdminAttendanceRow[] +} + export type ParentAttendanceRow = { firstname: string lastname: string @@ -158,6 +272,646 @@ export type ContactSubmitResponse = { message?: string } +export type BroadcastEmailParentOption = { + id: number + firstname?: string | null + lastname?: string | null + email?: string | null + school_id?: string | null +} + +export type BroadcastEmailSenderOption = { + key: string + label: string +} + +export type BroadcastEmailOptionsResponse = { + ok: true + parents: BroadcastEmailParentOption[] + fromOptions: BroadcastEmailSenderOption[] +} + +export type BroadcastEmailSendResponse = { + ok: boolean + message?: string + stats?: { + mode?: string + sent?: number + attempted?: number + failed?: number + } +} + +export type SchoolCalendarDetailRow = { + id: number + title: string + description?: string + event_type?: string | null + date: string + notify_parent?: number + notify_teacher?: number + notify_admin?: number + no_school?: number + semester?: string + school_year?: string + created_at?: string | null + updated_at?: string | null +} + +export type SchoolCalendarOptionsResponse = ApiEnvelope<{ + event_types: string[] + defaults: { + school_year?: string | null + semester?: string | null + } +}> + +export type SchoolCalendarListResponse = ApiEnvelope<{ + events: SchoolCalendarEventRow[] +}> + +export type SchoolCalendarShowResponse = ApiEnvelope<{ + event: SchoolCalendarDetailRow +}> + +export type ClassSectionRow = { + id: number + class_id?: number | null + class_section_id?: number | null + class_section_name?: string | null + class_name?: string | null + semester?: string | null + school_year?: string | null + created_at?: string | null + updated_at?: string | null +} + +export type ClassSectionsResponse = ApiEnvelope<{ + sections: ClassSectionRow[] + meta?: { + current_page?: number + per_page?: number + total?: number + last_page?: number + } +}> + +export type ClassSectionShowResponse = ApiEnvelope<{ + section: ClassSectionRow +}> + +export type StudentAssignmentRow = { + student_id: number + name: string + age?: number | string | null + email?: string | null + phone?: string | null + registration_grade?: string | null + class_section_name?: string | null + class_section_names?: string[] + class_section_ids?: number[] + new_student?: string | null + registration_date?: string | null + school_year?: string | null + semester?: string | null +} + +export type StudentAssignmentOption = { + id: number + class_section_id: number + class_section_name: string + school_year?: string | null +} + +export type StudentAssignmentsResponse = { + ok: true + students: StudentAssignmentRow[] + classes: StudentAssignmentOption[] + schoolYears: string[] + selectedYear?: string | null + currentYear?: string | null + isCurrentYear?: boolean +} + +export type ExamDraftRow = { + id: number + teacher_id: number + class_section_id: number + class_section_name?: string | null + semester?: string | null + school_year?: string | null + exam_type?: string | null + draft_title?: string | null + description?: string | null + teacher_file?: string | null + teacher_filename?: string | null + status?: string | null + admin_id?: number | null + admin_comments?: string | null + reviewed_at?: string | null + final_file?: string | null + final_filename?: string | null + final_pdf_file?: string | null + version?: number | null + previous_draft_id?: number | null + is_legacy?: boolean | number | null + teacher_first?: string | null + teacher_last?: string | null + admin_first?: string | null + admin_last?: string | null +} + +export type ExamDraftAdminResponse = { + ok: true + drafts: ExamDraftRow[] + class_sections: Array<{ class_section_id: number; class_section_name: string }> + legacy_by_class: Record< + string, + { + class_section_id: number + class_section_name: string + items: ExamDraftRow[] + } + > + exam_types: string[] + status_options: string[] + school_year?: string | null + semester?: string | null + allowed_extensions?: string[] + max_upload_bytes?: number +} + +export type GradingOverviewStudentRow = { + id: number + school_id?: string | null + firstname?: string | null + lastname?: string | null + class_id?: number + ptap?: number | string | null + semester_score?: number | string | null + attendance?: number | string | null + homework_avg?: number | string | null + project_avg?: number | string | null + quiz_avg?: number | string | null + participation?: number | string | null + midterm_exam?: number | string | null + final_exam?: number | string | null + matched_biz_csid?: number | null + matched_pk_csid?: number | null + placement_level?: string | null +} + +export type GradingOverviewResponse = { + ok: true + grades: Record> + students_by_section: Record + semester?: string | null + school_year?: string | null + requested_class_id?: number | null + semester_options?: string[] + scores_released?: boolean + scores_released_fall?: boolean + scores_released_spring?: boolean + score_locks?: Record +} + +export type IpBanRow = { + id: number + ip_address?: string | null + attempts?: number + last_attempt_at?: string | null + blocked_until?: string | null + is_active?: boolean + created_at?: string | null + updated_at?: string | null +} + +export type IpBansResponse = ApiEnvelope<{ + bans: IpBanRow[] + meta?: { + current_page?: number + per_page?: number + total?: number + last_page?: number + } +}> + +export type LateSlipLogRow = { + id: number + school_year?: string | null + semester?: string | null + student_name?: string | null + slip_date?: string | null + time_in?: string | null + grade?: string | null + reason?: string | null + admin_name?: string | null + printed_by?: string | null + printed_at?: string | null + created_at?: string | null + updated_at?: string | null +} + +export type LateSlipLogsResponse = ApiEnvelope<{ + logs: LateSlipLogRow[] + meta?: { + current_page?: number + per_page?: number + total?: number + last_page?: number + } +}> + +export type UserListRow = { + user?: { + id?: number + firstname?: string | null + lastname?: string | null + email?: string | null + status?: string | null + cellphone?: string | null + gender?: string | null + address_street?: string | null + city?: string | null + state?: string | null + zip?: string | null + school_year?: string | null + semester?: string | null + [key: string]: unknown + } | null + roles?: Array<{ id?: number; name?: string | null; slug?: string | null }> + role_ids?: number[] + parent_type?: string + second_from_parents?: string +} + +export type UsersResponse = { + ok: true + users: UserListRow[] +} + +export type NotificationAdminRow = { + id: number + firstname?: string | null + lastname?: string | null + email?: string | null +} + +export type NotificationAlertsResponse = { + admins: NotificationAdminRow[] + subjects: Record + assignedSubjects: Record> + tableReady: boolean +} + +export type PrintNotificationRecipientsResponse = { + admins: NotificationAdminRow[] + assigned: Record + tableReady: boolean +} + +export type FamilyGuardianRow = { + user_id?: number + firstname?: string | null + lastname?: string | null + email?: string | null + cellphone?: string | null + relation?: string | null + is_primary?: boolean + receive_emails?: boolean + receive_sms?: boolean +} + +export type FamilyStudentRow = { + id?: number + firstname?: string | null + lastname?: string | null + school_id?: string | null + class_section_name?: string | null + [key: string]: unknown +} + +export type FamilyRow = { + id: number + family_code?: string + household_name?: string + address_line1?: string + address_line2?: string + city?: string + state?: string + postal_code?: string + country?: string + primary_phone?: string + preferred_lang?: string + preferred_contact_method?: string + is_active?: boolean + is_primary_home?: boolean + guardians?: FamilyGuardianRow[] + students?: FamilyStudentRow[] + [key: string]: unknown +} + +export type FamilyAdminIndexResponse = ApiEnvelope<{ + student?: { id?: number; firstname?: string | null; lastname?: string | null } | null + families: FamilyRow[] + guardians: Record[] + students: Record[] + searchStudents: Record[] + searchGuardians: NotificationAdminRow[] + resolved_student_id?: number | null +}> + +export type PaypalTransactionRow = { + id: number + transaction_id?: string | null + order_id?: string | null + parent_school_id?: string | null + payer_email?: string | null + amount?: number + net_amount?: number + currency?: string | null + status?: string | null + event_type?: string | null + created_at?: string | null +} + +export type PaypalTransactionsResponse = { + ok: true + transactions: PaypalTransactionRow[] + pagination?: { + current_page?: number + per_page?: number + total?: number + last_page?: number + } +} + +export type RemovedStudentRow = { + id: number + school_id?: string | null + firstname?: string | null + lastname?: string | null + gender?: string | null + age?: number | string | null + class_sections?: string[] + class_section_name?: string | null +} + +export type RemovedStudentsResponse = { + ok: true + active_students: RemovedStudentRow[] + removed_students: RemovedStudentRow[] + school_year?: string | null +} + +export type StudentPromotionTotalsRow = { + class_id?: number | null + class_section_id?: number | null + class_section_name?: string | null + total?: number +} + +export type StudentPromotionTotalsResponse = { + ok: true + year?: string | null + rows: StudentPromotionTotalsRow[] +} + +export type StudentDirectoryRow = { + id: number + school_id?: string | null + firstname?: string | null + lastname?: string | null + dob?: string | null + age?: number | null + gender?: string | null + is_active?: boolean | number | null + registration_grade?: string | null + is_new?: boolean | number | null + photo_consent?: boolean | number | null + parent_id?: number | null + registration_date?: string | null + tuition_paid?: boolean | number | null + semester?: string | null + year_of_registration?: string | number | null + school_year?: string | null + medical_conditions?: string | null + allergies?: string | null +} + +export type StudentDirectoryResponse = { + ok: true + students: StudentDirectoryRow[] + school_year?: string | null + current_year?: string | null +} + +export type StudentParentResponse = { + ok: true + student_id: number + parent?: { + id?: number + firstname?: string | null + lastname?: string | null + email?: string | null + cellphone?: string | null + } | null +} + +export type StudentEmergencyContactResponse = { + ok: true + student_id: number + rows: Array> +} + +export type TeacherClassAssignmentRow = { + teacher_id: number + firstname?: string | null + lastname?: string | null + name?: string | null + email?: string | null + cellphone?: string | null + role?: string | null + school_year?: string | null + main_assignments?: Array<{ + section_id?: number + class_section_name?: string | null + role?: string | null + semester?: string | null + school_year?: string | null + }> + ta_assignments?: Array<{ + section_id?: number + class_section_name?: string | null + role?: string | null + semester?: string | null + school_year?: string | null + }> +} + +export type TeacherClassOption = { + class_section_id: number + class_section_name: string + class_id?: number | null + class_name?: string | null + teacher_role?: string | null + school_year?: string | null + semester?: string | null +} + +export type TeacherClassAssignmentsResponse = { + ok: true + school_year?: string | null + teachers: TeacherClassAssignmentRow[] + classes: TeacherClassOption[] +} + +export type TeacherSubmissionTeacherRow = { + id: number + label: string + role_key?: string | null +} + +export type TeacherSubmissionReportRow = { + class_section?: string | null + class_section_id?: number + teachers?: TeacherSubmissionTeacherRow[] + midterm_score_status?: string | null + midterm_comment_status?: string | null + participation_status?: string | null + ptap_comment_status?: string | null + attendance_status?: string | null + missing_items?: string[] + student_count?: number +} + +export type TeacherSubmissionsResponse = { + rows: TeacherSubmissionReportRow[] + semester?: string | null + schoolYear?: string | null + notificationHistory?: Record + summary?: { + total_items?: number + missing_items?: number + submitted_items?: number + submission_percentage?: number + } +} + +export type SubjectCurriculumClassRow = { + id: number + class_name: string + school_year?: string | null +} + +export type SubjectCurriculumEntryRow = { + id: number + class_id: number + class_name?: string | null + subject: string + unit_number?: number | string | null + unit_title?: string | null + chapter_name: string +} + +export type SubjectCurriculumResponse = { + ok: true + classes: SubjectCurriculumClassRow[] + entries: SubjectCurriculumEntryRow[] + subject_labels: Record +} + +export type PublicCompetitionRow = { + id: number + title?: string | null + class_section_id?: number | null + semester?: string | null + school_year?: string | null + is_published?: boolean | number | null + is_locked?: boolean | number | null + published_at?: string | null + [key: string]: unknown +} + +export type PublicCompetitionIndexResponse = { + status: boolean + data?: { + competitions: PublicCompetitionRow[] + } + message?: string +} + +export type PublicCompetitionWinnerRow = { + student_id?: number + class_section_id?: number + rank?: number + score?: number | string | null + prize_amount?: number | string | null + firstname?: string | null + lastname?: string | null + class_section_name?: string | null + name?: string | null + [key: string]: unknown +} + +export type PublicCompetitionShowResponse = { + status: boolean + data?: { + competition: PublicCompetitionRow + winners_by_class: Record + section_map: Record + question_counts: Record + } + message?: string +} + +export type CompetitionScoresIndexCompetitionRow = { + id: number + title?: string | null + class_section_id?: number | null + is_locked?: boolean | number | null + is_published?: boolean | number | null + [key: string]: unknown +} + +export type CompetitionScoresIndexResponse = { + ok: true + competitions: CompetitionScoresIndexCompetitionRow[] + activeClassId?: number + activeClassName?: string | null + questionCounts?: Record + scoreCounts?: Record + studentTotal?: number + sectionMap?: Record + schoolYear?: string | null + semester?: string | null + hasClasses?: boolean +} + +export type CompetitionScoreStudentRow = { + id?: number + student_id?: number + school_id?: string | null + firstname?: string | null + lastname?: string | null +} + +export type CompetitionScoresEditResponse = { + ok: boolean + competition?: PublicCompetitionRow + students?: CompetitionScoreStudentRow[] + scoreMap?: Record + classSectionId?: number + classSectionName?: string | null + classStudentCount?: number + questionCount?: number | null + classSelectionLocked?: boolean + isLocked?: boolean + schoolYear?: string | null + semester?: string | null + message?: string +} + /** Loose JSON payloads for complex parent dashboards (events, registration, attendance report form). */ export type ParentEventsResponse = Record export type ParentRegistrationResponse = Record @@ -259,6 +1013,36 @@ export type StudentScoresResponse = { rows: StudentScoreRow[] } +export type StudentScoreCardStudent = { + id: number + firstname?: string | null + lastname?: string | null + school_id?: string | null +} + +export type StudentScoreCardRow = { + school_year?: string | null + semester?: string | null + homework_avg?: number | string | null + project_avg?: number | string | null + participation_score?: number | string | null + quiz_avg?: number | string | null + test_avg?: number | string | null + attendance_score?: number | string | null + ptap_score?: number | string | null + midterm_exam_score?: number | string | null + semester_score?: number | string | null + class_section_name?: string | null + comments?: Record + year_score?: number | string | null +} + +export type StudentScoreCardResponse = { + ok: true + student: StudentScoreCardStudent + rows: StudentScoreCardRow[] +} + export type ReportCardStudentMetaRow = { id: number firstname: string @@ -289,6 +1073,7 @@ export type ClassProgressReportRow = { id: number teacher_name?: string | null class_section_name?: string | null + class_section_id?: number | null subject?: string | null unit_title?: string | null status?: string | null @@ -296,9 +1081,12 @@ export type ClassProgressReportRow = { covered?: string | null homework?: string | null support_needed?: string | null + flags?: string[] week_start?: string | null week_end?: string | null - attachments?: { id?: number; original_name?: string | null; url?: string | null }[] + created_at?: string | null + updated_at?: string | null + attachments?: { id?: number; name?: string | null; file_path?: string | null; download_url?: string | null }[] } export type ClassProgressGroupRow = { @@ -316,10 +1104,58 @@ export type ClassProgressGroupsResponse = ApiEnvelope<{ export type ClassProgressShowResponse = ApiEnvelope<{ report?: ClassProgressReportRow - weekly_reports?: Record + weekly_reports?: ClassProgressReportRow[] status_options?: Record }> +export type ClassProgressMetaResponse = ApiEnvelope<{ + subject_sections?: Record + status_options?: Record + sunday_options?: string[] + curriculum?: Record>> +}> + +export type EmergencyContactGroupRow = { + parent_id: number + parent_name?: string | null + parent_phones?: string[] + students?: Array<{ + id?: number + student_id?: number + firstname?: string | null + lastname?: string | null + }> + contacts?: Array<{ + id: number + parent_id?: number + name?: string | null + cellphone?: string | null + email?: string | null + relation?: string | null + semester?: string | null + school_year?: string | null + }> +} + +export type EmergencyContactGroupsResponse = { + ok: true + groups: EmergencyContactGroupRow[] +} + +export type EmergencyContactShowResponse = { + ok: true + contact: { + id: number + parent_id: number + name?: string | null + cellphone?: string | null + email?: string | null + relation?: string | null + semester?: string | null + school_year?: string | null + } +} + export type ParentRegistrationOverviewResponse = { ok: true parent: ParentProfileRecord | null diff --git a/src/lib/ciSpaPaths.ts b/src/lib/ciSpaPaths.ts index 7495084..e0cb781 100644 --- a/src/lib/ciSpaPaths.ts +++ b/src/lib/ciSpaPaths.ts @@ -22,6 +22,37 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null 'teacher/dashboard': '/app/teacher/dashboard', 'administrator/dashboard': '/app/administrator/dashboard', 'administrator/administratordashboard': '/app/admin/dashboard', + 'administrator/daily_attendance': '/app/administrator/daily_attendance', + 'administrator/daily_attendance_analysis': '/app/administrator/daily_attendance_analysis', + 'administrator/exam_drafts': '/app/administrator/exam_drafts', + 'administrator/grading_management': '/app/administrator/grading_management', + 'administrator/ip_bans': '/app/administrator/ip_bans', + 'administrator/late_slip_logs': '/app/administrator/late_slip_logs', + 'administrator/manage_users': '/app/administrator/manage_users', + 'administrator/notifications_alerts': '/app/administrator/notifications_alerts', + 'administrator/parent_profile': '/app/administrator/parent_profile', + 'administrator/paypal_transactions': '/app/administrator/paypal_transactions', + 'administrator/print_notification_admins': '/app/administrator/print_notification_admins', + 'administrator/removed_students': '/app/administrator/removed_students', + 'administrator/search_results': '/app/administrator/search_results', + 'administrator/sections_auto_distribute': '/app/administrator/sections_auto_distribute', + 'administrator/student_class_assignment': '/app/administrator/student_class_assignment', + 'administrator/student_profiles': '/app/administrator/student_profiles', + 'administrator/subject_curriculum': '/app/administrator/subject_curriculum', + 'administrator/teacher_class_assignment': '/app/administrator/teacher_class_assignment', + 'administrator/teacher_submissions': '/app/administrator/teacher_submissions', + 'admin/competition_winners': '/app/admin/competition-winners', + 'admin/competition_winners/index': '/app/admin/competition-winners', + 'admin/competition_winners/form': '/app/admin/competition-winners/create', + 'admin/competition_winners/preview': '/app/admin/competition-winners', + 'admin/competition_winners/scores': '/app/admin/competition-winners', + 'admin/competition_winners/winners': '/app/admin/competition-winners', + 'admin/class_progress_list': '/app/admin/progress', + 'admin/class_progress_view': '/app/admin/progress', + 'administrator/student_score_card': '/app/administrator/student-score-card', + 'administrator/emergency_contact': '/app/administrator/emergency-contacts', + 'administrator/emergency_contact/index': '/app/administrator/emergency-contacts', + 'administrator/emergency_contact/edit': '/app/administrator/emergency-contacts', 'nav-builder': '/app/nav-builder', 'api/nav-builder': '/app/nav-builder', 'parent/parent_dashboard': '/app/parent/home', diff --git a/src/pages/AdminProgressPages.tsx b/src/pages/AdminProgressPages.tsx new file mode 100644 index 0000000..40c3d44 --- /dev/null +++ b/src/pages/AdminProgressPages.tsx @@ -0,0 +1,625 @@ +import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { + deleteAdministratorEmergencyContact, + fetchAdministratorEmergencyContact, + fetchAdministratorEmergencyContacts, + fetchClassProgressDetail, + fetchClassProgressGroups, + fetchClassProgressMeta, + fetchClassSections, + fetchStudentScoreCard, + searchAdministratorDashboard, + updateAdministratorEmergencyContact, +} from '../api/session' +import type { + AdministratorDashboardSearchResponse, + ClassProgressGroupRow, + ClassProgressReportRow, + EmergencyContactGroupRow, + StudentScoreCardRow, +} from '../api/types' + +function PageShell({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
+

{title}

+
+ {children} +
+ ) +} + +function formatDate(value?: string | null, withTime = false) { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return withTime ? date.toLocaleString() : date.toLocaleDateString() +} + +function splitUnitTitle(unitTitle?: string | null) { + const raw = String(unitTitle ?? '').trim() + if (!raw) return { curriculum: [] as string[], custom: [] as string[] } + const segments = raw + .split(/\s*[;|]\s*/) + .map((part) => part.trim()) + .filter(Boolean) + const curriculum: string[] = [] + const custom: string[] = [] + for (const segment of segments) { + if (/custom/i.test(segment)) custom.push(segment.replace(/^custom[:\s-]*/i, '').trim() || segment) + else curriculum.push(segment) + } + if (curriculum.length === 0 && custom.length === 0) curriculum.push(raw) + return { curriculum, custom } +} + +function ClassProgressUnitDisplay({ + unitTitle, + isQuran, + compact, +}: { + unitTitle?: string | null + isQuran?: boolean + compact?: boolean +}) { + const { curriculum, custom } = splitUnitTitle(unitTitle) + const labelCurriculum = isQuran ? 'Surah / curriculum' : 'Unit / chapter' + const labelCustom = isQuran ? 'Custom Surah / Arabic' : 'Custom subject(s)' + + if (!String(unitTitle ?? '').trim()) return - + + if (compact) { + return ( + <> + {custom.length > 0 ? ( +
+ {isQuran ? 'Custom' : 'Custom subject'} + {custom.join(', ')} +
+ ) : null} + {curriculum.length > 0 ? ( +
0 ? ' mt-1' : ''}`}>{curriculum.join(' · ')}
+ ) : null} + + ) + } + + return ( + <> + {curriculum.length > 0 ?
{labelCurriculum}: {curriculum.join(' ; ')}
: null} + {custom.length > 0 ?
{labelCustom}: {custom.join(' ; ')}
: null} + + ) +} + +export function AdminClassProgressListPage() { + const [filters, setFilters] = useState({ weekStart: '', weekEnd: '', classSectionId: '', status: '' }) + const [subjectSections, setSubjectSections] = useState>({}) + const [statusOptions, setStatusOptions] = useState>({}) + const [classSections, setClassSections] = useState>([]) + const [items, setItems] = useState([]) + const [error, setError] = useState(null) + const navigate = useNavigate() + + async function load() { + try { + const [meta, sections, groups] = await Promise.all([ + fetchClassProgressMeta(), + fetchClassSections({ perPage: 200 }), + fetchClassProgressGroups({ + classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null, + status: filters.status || null, + weekStart: filters.weekStart || null, + weekEnd: filters.weekEnd || null, + perPage: 200, + }), + ]) + setSubjectSections(meta.data?.subject_sections ?? {}) + setStatusOptions(meta.data?.status_options ?? {}) + setClassSections(sections.data?.sections ?? []) + setItems(groups.data?.items ?? []) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unable to load class progress.') + } + } + + useEffect(() => { + void load() + }, []) + + const itemsBySection = useMemo(() => { + const map = new Map() + for (const item of items) { + const key = String(item.class_section_id ?? '0') + const bucket = map.get(key) ?? [] + bucket.push(item) + map.set(key, bucket) + } + return Array.from(map.entries()) + }, [items]) + + return ( + + {error ?
{error}
: null} +
+
Filter by week, class, and status
+ + Teachers < 50% + +
+
{ e.preventDefault(); void load() }}> +
+
+
setFilters((current) => ({ ...current, weekStart: e.target.value }))} />
+
setFilters((current) => ({ ...current, weekEnd: e.target.value }))} />
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+ {itemsBySection.length === 0 ? ( +
No reports found.
+ ) : ( +
+ {itemsBySection.map(([sectionId, groups]) => ( +
+

+ +

+
+
+
+ + + + + + {groups.map((group) => { + const reports = group.reports ?? {} + const teacherSet = Array.from(new Set(Object.values(reports).map((report) => report.teacher_name).filter(Boolean))) + const firstReport = Object.values(reports)[0] + return ( + + + + + + + ) + })} + +
WeekSubjectsTeacherAction
+
{formatDate(group.week_start)} - {formatDate(group.week_end)}
+
+
+ {Object.entries(subjectSections).map(([slug, section]) => { + const subjectName = section.db_subject ?? section.label ?? slug + const report = reports[subjectName] + return ( +
+
+ {section.label ?? subjectName} + {report?.status_label ?? 'No entry'} +
+
+ {report ? ( + + ) : ( + No submission + )} +
+
+ ) + })} +
+
{teacherSet.join(', ') || '—'} + {firstReport?.id ? ( + + ) : null} +
+
+
+
+
+ ))} +
+ )} +
+
+
+ ) +} + +export function AdminClassProgressViewPage() { + const { id } = useParams() + const reportId = Number(id) + const [report, setReport] = useState(null) + const [weeklyReports, setWeeklyReports] = useState([]) + const [subjectSections, setSubjectSections] = useState>({}) + const [error, setError] = useState(null) + + useEffect(() => { + if (!reportId) return + ;(async () => { + try { + const [detail, meta] = await Promise.all([fetchClassProgressDetail(reportId), fetchClassProgressMeta()]) + setReport(detail.data?.report ?? null) + setWeeklyReports(detail.data?.weekly_reports ?? []) + setSubjectSections(meta.data?.subject_sections ?? {}) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unable to load progress report.') + } + })() + }, [reportId]) + + const reportsBySubject = useMemo( + () => Object.fromEntries(weeklyReports.map((entry) => [String(entry.subject ?? ''), entry])), + [weeklyReports], + ) + + const attachmentList = weeklyReports.flatMap((entry) => (entry.attachments ?? []).map((attachment) => ({ + subject: entry.subject ?? 'Attachment', + attachment, + }))) + + return ( + + {error ?
{error}
: null} + {report ? ( + <> +
+
{report.class_section_name ?? '-'} • {formatDate(report.week_start)} → {formatDate(report.week_end)}
+ Back +
+
+
+ {Object.entries(subjectSections).map(([slug, section]) => { + const subjectName = section.db_subject ?? section.label ?? slug + const entry = reportsBySubject[subjectName] + return ( +
+
{section.label ?? subjectName}
+
+ {!entry ? ( +
No entry submitted for this subject this week.
+ ) : ( + <> +
+ +
+
Activities
{entry.covered || '-'}
+
{subjectName === 'Quran/Arabic' ? 'Arabic Practice / Homework' : 'Assigned Homework'}:
{entry.homework || '-'}
+ {(entry.attachments ?? []).length > 0 ? ( +
+ Attachments: +
+ {(entry.attachments ?? []).map((attachment) => ( + + {attachment.name ?? 'Attachment'} + + ))} +
+
+ ) : null} + + )} +
+
+ ) + })} +
+
+
+
Summary
+
+
Teacher: {report.teacher_name ?? '-'}
+
Submitted: {formatDate(report.created_at, true)}
+
Status: {report.status_label ?? 'Unknown'}
+
+
+ {(report.flags ?? []).length > 0 ? ( +
+
Checklist
+
+ {(report.flags ?? []).map((flag) => {flag.replace(/_/g, ' ')})} +
+
+ ) : null} + {attachmentList.length > 0 ? ( +
+
Attachments
+
+ {attachmentList.map(({ subject, attachment }, index) => ( + + {`${subject} • ${attachment.name ?? 'Attachment'}`} + + ))} +
+
+ ) : null} +
+
+ + ) : null} +
+ ) +} + +export function AdminStudentScoreCardPage() { + const [query, setQuery] = useState('') + const [results, setResults] = useState>>([]) + const [selectedStudentId, setSelectedStudentId] = useState(null) + const [selectedStudent, setSelectedStudent] = useState<{ firstname?: string | null; lastname?: string | null; school_id?: string | null } | null>(null) + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + + async function runSearch(e?: FormEvent) { + e?.preventDefault() + if (!query.trim()) return + try { + const data: AdministratorDashboardSearchResponse = await searchAdministratorDashboard(query.trim()) + setResults(data.results?.students ?? []) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to search students.') + } + } + + async function openScoreCard(studentId: number) { + try { + const data = await fetchStudentScoreCard(studentId) + setSelectedStudentId(studentId) + setSelectedStudent(data.student) + setRows(data.rows ?? []) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to load student score card.') + } + } + + return ( + + {error ?
{error}
: null} +
+
+
void runSearch(e)}> +
+
+
+ Search Student + setQuery(e.target.value)} placeholder="Type School ID or Student Name" /> + +
+ Searches all grades. +
+
+ +
+
+
+ + {query.trim() && results.length === 0 ?
No students found.
: null} + {results.length > 0 ? ( +
+
ResultsShowing matching students
+
+ + + + {results.map((row) => ( + + + + + + ))} + +
School IDStudent
{String(row.school_id ?? '')}{`${String(row.firstname ?? '')} ${String(row.lastname ?? '')}`.trim()} + +
+
+
+ ) : null} + + {selectedStudentId && selectedStudent ? ( +
+
+ {`${selectedStudent.firstname ?? ''} ${selectedStudent.lastname ?? ''}`.trim()} + {selectedStudent.school_id ?? ''} +
+
+ + + + + + + + + + + + + + + + + + + + {rows.length === 0 ? : rows.map((row, index) => ( + + + + + + + + + + + + + + + + ))} + +
School YearSemesterClassHomeworkProjectParticipationQuizTestAttendancePTAPMidtermSemesterYear Avg
No score rows found.
{row.school_year ?? '—'}{row.semester ?? '—'}{row.class_section_name ?? '—'}{row.homework_avg ?? '—'}{row.project_avg ?? '—'}{row.participation_score ?? '—'}{row.quiz_avg ?? '—'}{row.test_avg ?? '—'}{row.attendance_score ?? '—'}{row.ptap_score ?? '—'}{row.midterm_exam_score ?? '—'}{row.semester_score ?? '—'}{row.year_score ?? '—'}
+
+
+ ) : null} +
+ ) +} + +export function AdminEmergencyContactIndexPage() { + const [groups, setGroups] = useState([]) + const [error, setError] = useState(null) + + async function load() { + try { + const data = await fetchAdministratorEmergencyContacts() + setGroups(data.groups ?? []) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unable to load emergency contacts.') + } + } + + useEffect(() => { + void load() + }, []) + + async function removeContact(contactId: number, parentId: number) { + await deleteAdministratorEmergencyContact(contactId, parentId) + await load() + } + + return ( + + {error ?
{error}
: null} +
+ + + + + + {groups.flatMap((group) => + (group.contacts ?? []).map((contact) => ( + + + + + + + + + )), + )} + {groups.length === 0 ? : null} + +
Parent NameStudentsContact NameRelationPhoneActions
{group.parent_name ?? '—'}{(group.students ?? []).map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()).join(', ') || '—'}{contact.name ?? '—'}{contact.relation ?? '—'}{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'} + Edit + +
No emergency contacts found.
+
+
+ ) +} + +export function AdminEmergencyContactEditPage() { + const { contactId } = useParams() + const [searchParams] = useSearchParams() + const parentId = Number(searchParams.get('parent_id') ?? 0) + const [form, setForm] = useState({ name: '', cellphone: '', email: '', relation: '' }) + const [error, setError] = useState(null) + const [message, setMessage] = useState(null) + const navigate = useNavigate() + + useEffect(() => { + if (!contactId || !parentId) return + ;(async () => { + try { + const data = await fetchAdministratorEmergencyContact(Number(contactId), parentId) + setForm({ + name: data.contact.name ?? '', + cellphone: data.contact.cellphone ?? '', + email: data.contact.email ?? '', + relation: data.contact.relation ?? '', + }) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unable to load emergency contact.') + } + })() + }, [contactId, parentId]) + + async function onSubmit(e: FormEvent) { + e.preventDefault() + if (!contactId || !parentId) return + try { + await updateAdministratorEmergencyContact(Number(contactId), { + parent_id: parentId, + name: form.name, + cellphone: form.cellphone, + email: form.email || null, + relation: form.relation || null, + }) + setMessage('Emergency contact updated.') + setError(null) + navigate('/app/administrator/emergency-contacts') + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to update emergency contact.') + } + } + + return ( + + {error ?
{error}
: null} + {message ?
{message}
: null} +
+
+
void onSubmit(e)}> +
setForm((current) => ({ ...current, name: e.target.value }))} required />
+
setForm((current) => ({ ...current, cellphone: e.target.value }))} required />
+
setForm((current) => ({ ...current, email: e.target.value }))} />
+
setForm((current) => ({ ...current, relation: e.target.value }))} />
+
+ + Cancel +
+
+
+
+
+ ) +} diff --git a/src/pages/AdministratorToolPages.tsx b/src/pages/AdministratorToolPages.tsx new file mode 100644 index 0000000..3c3fddd --- /dev/null +++ b/src/pages/AdministratorToolPages.tsx @@ -0,0 +1,3321 @@ +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} + /> + +
+
+ +
+ +