add admin pages

This commit is contained in:
root
2026-04-23 15:28:56 -04:00
parent 94700d4f0f
commit 7fe34dde0d
9 changed files with 6513 additions and 99 deletions
+222
View File
@@ -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() {
<Route index element={<Navigate to="home" replace />} />
<Route path="select-role" element={<RoleSelectPage />} />
<Route path="home" element={<AppHome />} />
<Route path="admin/dashboard" element={<AppHome />} />
<Route path="administrator/dashboard" element={<AppHome />} />
<Route path="administrator/administratordashboard" element={<AppHome />} />
<Route path="administrator/broadcast_email" element={<AdminBroadcastEmailPage />} />
<Route path="administrator/calendar" element={<AdminCalendarPage />} />
<Route path="administrator/calendar_view" element={<AdminCalendarViewPage />} />
<Route path="administrator/calendar_add_event" element={<AdminCalendarAddEventPage />} />
<Route path="administrator/calendar_edit/:eventId" element={<AdminCalendarEditPage />} />
<Route path="administrator/class_assignment" element={<AdminClassAssignmentPage />} />
<Route path="administrator/class_section" element={<AdminClassSectionPage />} />
<Route path="administrator/class_warning_modal" element={<AdminClassWarningModalPage />} />
<Route path="administrator/course_materials" element={<AdminCourseMaterialsPage />} />
<Route path="administrator/daily_attendance" element={<AdminDailyAttendancePage />} />
<Route path="administrator/daily_attendance_analysis" element={<AdminDailyAttendanceAnalysisPage />} />
<Route path="administrator/exam_drafts" element={<AdminExamDraftsPage />} />
<Route path="administrator/grading_management" element={<AdminGradingManagementPage />} />
<Route path="administrator/ip_bans" element={<AdminIpBansPage />} />
<Route path="administrator/late_slip_logs" element={<AdminLateSlipLogsPage />} />
<Route path="administrator/manage_users" element={<AdminManageUsersPage />} />
<Route path="administrator/notifications_alerts" element={<AdminNotificationsAlertsPage />} />
<Route path="administrator/parent_profile" element={<AdminParentProfilePage />} />
<Route path="administrator/paypal_transactions" element={<AdminPaypalTransactionsPage />} />
<Route path="administrator/print_notification_admins" element={<AdminPrintNotificationAdminsPage />} />
<Route path="administrator/removed_students" element={<AdminRemovedStudentsPage />} />
<Route path="administrator/search_results" element={<AdminSearchResultsPage />} />
<Route path="administrator/sections_auto_distribute" element={<AdminSectionsAutoDistributePage />} />
<Route path="administrator/student_class_assignment" element={<AdminStudentClassAssignmentPage />} />
<Route path="administrator/student_profiles" element={<AdminStudentProfilesPage />} />
<Route path="administrator/subject_curriculum" element={<AdminSubjectCurriculumPage />} />
<Route path="administrator/teacher_class_assignment" element={<AdminTeacherClassAssignmentPage />} />
<Route path="administrator/teacher_submissions" element={<AdminTeacherSubmissionsPage />} />
<Route path="admin/competition-winners" element={<CompetitionWinnersIndexPage />} />
<Route path="admin/competition-winners/create" element={<CompetitionWinnerFormPage />} />
<Route path="admin/competition-winners/:id/settings" element={<CompetitionWinnerFormPage />} />
<Route path="admin/competition-winners/:id" element={<CompetitionWinnerScoresPage />} />
<Route path="admin/competition-winners/:id/preview" element={<CompetitionWinnerPreviewPage />} />
<Route path="admin/competition-winners/:id/winners" element={<CompetitionWinnerWinnersPage />} />
<Route path="admin/progress" element={<AdminClassProgressListPage />} />
<Route path="admin/progress/view/:id" element={<AdminClassProgressViewPage />} />
<Route path="administrator/student-score-card" element={<AdminStudentScoreCardPage />} />
<Route path="administrator/emergency-contacts" element={<AdminEmergencyContactIndexPage />} />
<Route path="administrator/emergency-contacts/:contactId/edit" element={<AdminEmergencyContactEditPage />} />
<Route path="nav-builder" element={<NavBuilderPage />} />
</Route>
+818 -2
View File
@@ -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<ApiEnvelope<DashboardPayloa
return apiFetch<ApiEnvelope<DashboardPayload>>('/api/v1/dashboard/route')
}
export async function fetchAdministratorDashboardMetrics(): Promise<AdministratorDashboardMetricsResponse> {
return apiFetch<AdministratorDashboardMetricsResponse>('/api/v1/administrator/dashboard/metrics')
}
export async function searchAdministratorDashboard(query: string): Promise<AdministratorDashboardSearchResponse> {
const q = query.trim()
const qs = q ? `?query=${encodeURIComponent(q)}` : ''
return apiFetch<AdministratorDashboardSearchResponse>(`/api/v1/administrator/dashboard/user-search${qs}`)
}
export async function fetchAdministratorAbsenceForm(): Promise<AdministratorAbsenceFormResponse> {
return apiFetch<AdministratorAbsenceFormResponse>('/api/v1/administrator/absence')
}
export async function submitAdministratorAbsence(payload: {
dates: string[]
reason_type?: string
reason: string
}): Promise<AdministratorAbsenceSubmitResponse> {
return apiFetch<AdministratorAbsenceSubmitResponse>('/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<AdministratorDailyAttendanceResponse> {
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<AdministratorDailyAttendanceResponse>(`/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<AdministratorAdminAttendanceResponse> {
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<AdministratorAdminAttendanceResponse>(`/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<BroadcastEmailOptionsResponse> {
return apiFetch<BroadcastEmailOptionsResponse>('/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<BroadcastEmailSendResponse> {
return apiFetch<BroadcastEmailSendResponse>('/api/v1/broadcast-email/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function fetchSchoolCalendarOptions(): Promise<SchoolCalendarOptionsResponse> {
return apiFetch<SchoolCalendarOptionsResponse>('/api/v1/settings/school-calendar/options')
}
export async function fetchAdministratorCalendarEvents(params?: {
schoolYear?: string | null
semester?: string | null
audience?: 'admin' | 'teacher' | 'parent' | null
}): Promise<SchoolCalendarListResponse> {
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<SchoolCalendarListResponse>(`/api/v1/settings/school-calendar/events${qs}`)
}
export async function fetchAdministratorCalendarEvent(eventId: number): Promise<SchoolCalendarShowResponse> {
return apiFetch<SchoolCalendarShowResponse>(`/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<SchoolCalendarShowResponse> {
return apiFetch<SchoolCalendarShowResponse>('/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<string, unknown>,
): Promise<SchoolCalendarShowResponse> {
return apiFetch<SchoolCalendarShowResponse>(`/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<ApiEnvelope<null>> {
return apiFetch<ApiEnvelope<null>>(`/api/v1/settings/school-calendar/events/${eventId}`, {
method: 'DELETE',
})
}
export async function fetchStudentAssignments(
schoolYear?: string | null,
): Promise<StudentAssignmentsResponse> {
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<StudentAssignmentsResponse>(`/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<RemovedStudentsResponse> {
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<RemovedStudentsResponse>(`/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<ApiEnvelope<{ sections?: Array<Record<string, unknown>>; message?: string }>> {
return apiFetch<ApiEnvelope<{ sections?: Array<Record<string, unknown>>; 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<StudentPromotionTotalsResponse> {
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<StudentPromotionTotalsResponse>(`/api/v1/students/promotion-totals${query}`)
}
export async function fetchStudentDirectory(
schoolYear?: string | null,
): Promise<StudentDirectoryResponse> {
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<StudentDirectoryResponse>(`/api/v1/students${query}`)
}
export async function updateStudentProfile(
studentId: number,
payload: Record<string, unknown>,
): 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<StudentParentResponse> {
return apiFetch<StudentParentResponse>(`/api/v1/students/${studentId}/parents`)
}
export async function fetchStudentEmergencyContacts(
studentId: number,
): Promise<StudentEmergencyContactResponse> {
return apiFetch<StudentEmergencyContactResponse>(`/api/v1/students/${studentId}/emergency-contacts`)
}
export async function fetchTeacherClassAssignments(
schoolYear?: string | null,
): Promise<TeacherClassAssignmentsResponse> {
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<TeacherClassAssignmentsResponse>(
`/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<TeacherSubmissionsResponse> {
return apiFetch<TeacherSubmissionsResponse>('/api/v1/administrator/teacher-submissions')
}
export async function fetchPublishedCompetitions(): Promise<PublicCompetitionIndexResponse> {
return apiFetch<PublicCompetitionIndexResponse>('/api/winners/competitions', {}, { attachAuth: false })
}
export async function fetchPublishedCompetition(id: number): Promise<PublicCompetitionShowResponse> {
return apiFetch<PublicCompetitionShowResponse>(`/api/winners/competitions/${id}`, {}, { attachAuth: false })
}
export async function fetchCompetitionScoresIndex(
classSectionId?: number | null,
): Promise<CompetitionScoresIndexResponse> {
const query = classSectionId ? `?class_section_id=${encodeURIComponent(String(classSectionId))}` : ''
return apiFetch<CompetitionScoresIndexResponse>(`/api/v1/competition-scores${query}`)
}
export async function fetchCompetitionScoresDetail(
id: number,
classSectionId?: number | null,
): Promise<CompetitionScoresEditResponse> {
const query = classSectionId ? `?class_section_id=${encodeURIComponent(String(classSectionId))}` : ''
return apiFetch<CompetitionScoresEditResponse>(`/api/v1/competition-scores/${id}${query}`)
}
export async function saveCompetitionScores(
id: number,
payload: { class_section_id?: number | null; scores: Record<string, number | string> },
): 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<string, string[]>
}): 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<SubjectCurriculumResponse> {
return apiFetch<SubjectCurriculumResponse>('/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<ApiEnvelope<{ entry?: unknown }>> {
return apiFetch<ApiEnvelope<{ entry?: unknown }>>('/api/v1/subjects/curriculum', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function updateSubjectCurriculum(
id: number,
payload: Record<string, unknown>,
): Promise<ApiEnvelope<{ entry?: unknown }>> {
return apiFetch<ApiEnvelope<{ entry?: unknown }>>(`/api/v1/subjects/curriculum/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function deleteSubjectCurriculum(id: number): Promise<ApiEnvelope<null>> {
return apiFetch<ApiEnvelope<null>>(`/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<ClassSectionsResponse> {
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<ClassSectionsResponse>(`/api/v1/class-sections${qs}`)
}
export async function fetchClassSection(sectionId: number): Promise<ClassSectionShowResponse> {
return apiFetch<ClassSectionShowResponse>(`/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<ClassSectionShowResponse> {
return apiFetch<ClassSectionShowResponse>('/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<ClassSectionShowResponse> {
return apiFetch<ClassSectionShowResponse>(`/api/v1/class-sections/${sectionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function deleteClassSection(sectionId: number): Promise<ApiEnvelope<null>> {
return apiFetch<ApiEnvelope<null>>(`/api/v1/class-sections/${sectionId}`, {
method: 'DELETE',
})
}
export async function fetchExamDraftAdminData(): Promise<ExamDraftAdminResponse> {
return apiFetch<ExamDraftAdminResponse>('/api/v1/exams/drafts/admin')
}
async function fetchMultipartJson<T>(path: string, formData: FormData): Promise<T> {
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<GradingOverviewResponse> {
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<GradingOverviewResponse>(`/api/v1/grading/overview${qs}`)
}
export async function fetchIpBans(params?: {
status?: 'all' | 'active'
page?: number
perPage?: number
}): Promise<IpBansResponse> {
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<IpBansResponse>(`/api/v1/ip-bans${qs}`)
}
export async function banIp(payload: {
id?: number
ip?: string
hours?: number
}): Promise<ApiEnvelope<{ ban?: unknown }>> {
return apiFetch<ApiEnvelope<{ ban?: unknown }>>('/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<ApiEnvelope<{ ban?: unknown; count?: number }>> {
return apiFetch<ApiEnvelope<{ ban?: unknown; count?: number }>>('/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<LateSlipLogsResponse> {
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<LateSlipLogsResponse>(`/api/v1/attendance/late-slip-logs${qs}`)
}
export async function deleteLateSlipLog(id: number): Promise<ApiEnvelope<null>> {
return apiFetch<ApiEnvelope<null>>(`/api/v1/attendance/late-slip-logs/${id}`, {
method: 'DELETE',
})
}
export async function fetchUsers(params?: {
sort?: string
order?: 'asc' | 'desc'
}): Promise<UsersResponse> {
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<UsersResponse>(`/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<NotificationAlertsResponse> {
return apiFetch<NotificationAlertsResponse>('/api/v1/administrator/notifications/alerts')
}
export async function saveNotificationAlerts(
subjects: Record<string, string[]>,
): 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<PrintNotificationRecipientsResponse> {
return apiFetch<PrintNotificationRecipientsResponse>(
'/api/v1/administrator/notifications/print-recipients',
)
}
export async function savePrintNotificationRecipients(
notify: Record<string, number | boolean>,
): 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<FamilyAdminIndexResponse> {
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<FamilyAdminIndexResponse>(`/api/v1/family-admin${qs}`)
}
export async function fetchPaypalTransactions(params?: {
q?: string
perPage?: number
}): Promise<PaypalTransactionsResponse> {
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<PaypalTransactionsResponse>(`/api/v1/paypal-transactions${qs}`)
}
export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promise<Blob> {
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<NavMenuResponse> {
return apiFetch<NavMenuResponse>('/api/v1/nav-builder/menu')
}
@@ -208,14 +943,95 @@ export async function fetchReportCardAcknowledgement(
)
}
export async function fetchClassProgressGroups(): Promise<ClassProgressGroupsResponse> {
return apiFetch<ClassProgressGroupsResponse>('/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<ClassProgressGroupsResponse> {
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<ClassProgressGroupsResponse>(`/api/v1/class-progress?${query.toString()}`)
}
export async function fetchClassProgressDetail(id: number): Promise<ClassProgressShowResponse> {
return apiFetch<ClassProgressShowResponse>(`/api/v1/class-progress/${id}`)
}
export async function fetchClassProgressMeta(params?: {
classId?: number | null
sundayCount?: number | null
}): Promise<ClassProgressMetaResponse> {
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<ClassProgressMetaResponse>(`/api/v1/class-progress/meta${qs}`)
}
export async function fetchStudentScoreCard(studentId: number): Promise<StudentScoreCardResponse> {
return apiFetch<StudentScoreCardResponse>(`/api/v1/students/${studentId}/score-card`)
}
export async function fetchAdministratorEmergencyContacts(params?: {
parentId?: number | null
parentIds?: number[]
}): Promise<EmergencyContactGroupsResponse> {
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<EmergencyContactGroupsResponse>(`/api/v1/administrator/emergency-contacts${qs}`)
}
export async function fetchAdministratorEmergencyContact(
contactId: number,
parentId: number,
): Promise<EmergencyContactShowResponse> {
return apiFetch<EmergencyContactShowResponse>(
`/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<EmergencyContactShowResponse> {
return apiFetch<EmergencyContactShowResponse>(`/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<
+838 -2
View File
@@ -87,6 +87,120 @@ export type NavBuilderDataResponse = ApiEnvelope<{ data: NavBuilderData }>
export type NavBuilderSaveResponse = ApiEnvelope<{ id: number }>
export type NavBuilderDeleteResponse = ApiEnvelope<null>
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<string, unknown>[]
students?: Record<string, unknown>[]
parents?: Record<string, unknown>[]
staff?: Record<string, unknown>[]
emergency_contacts?: Record<string, unknown>[]
}
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<string, Record<string, AdministratorDailyAttendanceEntry[]>>
attendance_record?: Record<string, Record<string, AdministratorDailyAttendanceSummary>>
students_by_section?: Record<string, AdministratorDailyAttendanceStudent[]>
grades?: Record<string, AdministratorDailyAttendanceSection[]>
dates_by_section?: Record<string, string[]>
date_list?: string[]
no_school_days?: Record<string, boolean>
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<string, Array<{ class_section_id: number; class_section_name: string }>>
students_by_section: Record<string, GradingOverviewStudentRow[]>
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<string, boolean>
}
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<string, string>
assignedSubjects: Record<string, Record<string, boolean>>
tableReady: boolean
}
export type PrintNotificationRecipientsResponse = {
admins: NotificationAdminRow[]
assigned: Record<string, boolean>
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<string, unknown>[]
students: Record<string, unknown>[]
searchStudents: Record<string, unknown>[]
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<Record<string, unknown>>
}
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<string, unknown>
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<string, string>
}
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<string, PublicCompetitionWinnerRow[]>
section_map: Record<string, string>
question_counts: Record<string, number>
}
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<string, number>
scoreCounts?: Record<string, number>
studentTotal?: number
sectionMap?: Record<string, string>
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<string, number | string>
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<string, unknown>
export type ParentRegistrationResponse = Record<string, unknown>
@@ -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<string, string>
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<string, ClassProgressReportRow>
weekly_reports?: ClassProgressReportRow[]
status_options?: Record<string, string>
}>
export type ClassProgressMetaResponse = ApiEnvelope<{
subject_sections?: Record<string, { label?: string; db_subject?: string }>
status_options?: Record<string, string>
sunday_options?: string[]
curriculum?: Record<string, Array<Record<string, unknown>>>
}>
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
+31
View File
@@ -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',
+625
View File
@@ -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 (
<div className="container-fluid py-3">
<div className="mb-4">
<h2 className="h4 mb-1">{title}</h2>
</div>
{children}
</div>
)
}
function formatDate(value?: string | null, 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 <span className="text-muted">-</span>
if (compact) {
return (
<>
{custom.length > 0 ? (
<div className="small">
<span className="badge text-bg-info me-1">{isQuran ? 'Custom' : 'Custom subject'}</span>
{custom.join(', ')}
</div>
) : null}
{curriculum.length > 0 ? (
<div className={`small text-muted${custom.length > 0 ? ' mt-1' : ''}`}>{curriculum.join(' · ')}</div>
) : null}
</>
)
}
return (
<>
{curriculum.length > 0 ? <div className="mb-2"><strong>{labelCurriculum}:</strong> {curriculum.join(' ; ')}</div> : null}
{custom.length > 0 ? <div className="mb-2"><strong>{labelCustom}:</strong> {custom.join(' ; ')}</div> : null}
</>
)
}
export function AdminClassProgressListPage() {
const [filters, setFilters] = useState({ weekStart: '', weekEnd: '', classSectionId: '', status: '' })
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
const [statusOptions, setStatusOptions] = useState<Record<string, string>>({})
const [classSections, setClassSections] = useState<Array<{ class_section_id?: number | null; class_section_name?: string | null }>>([])
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
const [error, setError] = useState<string | null>(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<string, ClassProgressGroupRow[]>()
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 (
<PageShell title="Class Progress Reports">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex align-items-center justify-content-between mb-4">
<div className="text-muted">Filter by week, class, and status</div>
<Link className="btn btn-sm btn-outline-warning" to="/app/administrator/teacher_submissions">
Teachers &lt; 50%
</Link>
</div>
<form className="card shadow-sm mb-3" onSubmit={(e) => { e.preventDefault(); void load() }}>
<div className="card-body">
<div className="row g-3">
<div className="col-md-3"><label className="form-label">From</label><input type="date" className="form-control" value={filters.weekStart} onChange={(e) => setFilters((current) => ({ ...current, weekStart: e.target.value }))} /></div>
<div className="col-md-3"><label className="form-label">To</label><input type="date" className="form-control" value={filters.weekEnd} onChange={(e) => setFilters((current) => ({ ...current, weekEnd: e.target.value }))} /></div>
<div className="col-md-3">
<label className="form-label">Class/Section</label>
<select className="form-select" value={filters.classSectionId} onChange={(e) => setFilters((current) => ({ ...current, classSectionId: e.target.value }))}>
<option value="">All</option>
{classSections.map((section) => <option key={String(section.class_section_id ?? '')} value={section.class_section_id ?? ''}>{section.class_section_name}</option>)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Status</label>
<select className="form-select" value={filters.status} onChange={(e) => setFilters((current) => ({ ...current, status: e.target.value }))}>
<option value="">All</option>
{Object.entries(statusOptions).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
</div>
<div className="d-flex gap-2 mt-3">
<button className="btn btn-primary" type="submit">Apply</button>
<button className="btn btn-outline-secondary" type="button" onClick={() => { setFilters({ weekStart: '', weekEnd: '', classSectionId: '', status: '' }); void setTimeout(load, 0) }}>Reset</button>
</div>
</div>
</form>
<div className="card shadow-sm mt-4">
<div className="card-body pt-4">
{itemsBySection.length === 0 ? (
<div className="text-center text-muted py-4">No reports found.</div>
) : (
<div className="accordion" id="adminProgressAccordion">
{itemsBySection.map(([sectionId, groups]) => (
<div className="accordion-item mb-2" key={sectionId}>
<h2 className="accordion-header" id={`progress-heading-${sectionId}`}>
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target={`#progress-section-${sectionId}`}>
{groups[0]?.class_section_name ?? `Section ${sectionId}`}
<span className="badge bg-secondary ms-2">{groups.length} weeks</span>
</button>
</h2>
<div id={`progress-section-${sectionId}`} className="accordion-collapse collapse" data-bs-parent="#adminProgressAccordion">
<div className="accordion-body">
<div className="table-responsive">
<table className="table table-hover align-middle mb-0" data-no-mgmt-sticky>
<thead className="table-light">
<tr><th>Week</th><th>Subjects</th><th>Teacher</th><th className="text-end">Action</th></tr>
</thead>
<tbody>
{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 (
<tr key={`${sectionId}-${group.week_start}`}>
<td>
<div className="fw-semibold">{formatDate(group.week_start)} - {formatDate(group.week_end)}</div>
</td>
<td>
<div className="d-flex flex-column gap-2">
{Object.entries(subjectSections).map(([slug, section]) => {
const subjectName = section.db_subject ?? section.label ?? slug
const report = reports[subjectName]
return (
<div key={slug} className="border rounded-3 p-2">
<div className="d-flex justify-content-between align-items-center">
<strong className="small mb-0">{section.label ?? subjectName}</strong>
<span className={`badge ${report ? 'bg-secondary' : 'bg-light text-muted'}`}>{report?.status_label ?? 'No entry'}</span>
</div>
<div className="small">
{report ? (
<ClassProgressUnitDisplay unitTitle={report.unit_title} isQuran={subjectName === 'Quran/Arabic'} compact />
) : (
<span className="text-muted">No submission</span>
)}
</div>
</div>
)
})}
</div>
</td>
<td>{teacherSet.join(', ') || '—'}</td>
<td className="text-end">
{firstReport?.id ? (
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => navigate(`/app/admin/progress/view/${firstReport.id}`)}>
View
</button>
) : null}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</PageShell>
)
}
export function AdminClassProgressViewPage() {
const { id } = useParams()
const reportId = Number(id)
const [report, setReport] = useState<ClassProgressReportRow | null>(null)
const [weeklyReports, setWeeklyReports] = useState<ClassProgressReportRow[]>([])
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
const [error, setError] = useState<string | null>(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 (
<PageShell title="Progress Report">
{error ? <div className="alert alert-danger">{error}</div> : null}
{report ? (
<>
<div className="d-flex align-items-center justify-content-between mb-3">
<div className="text-muted">{report.class_section_name ?? '-'} {formatDate(report.week_start)} {formatDate(report.week_end)}</div>
<Link to="/app/admin/progress" className="btn btn-outline-secondary">Back</Link>
</div>
<div className="row g-3">
<div className="col-lg-8">
{Object.entries(subjectSections).map(([slug, section]) => {
const subjectName = section.db_subject ?? section.label ?? slug
const entry = reportsBySubject[subjectName]
return (
<div className="card shadow-sm mb-3" key={slug}>
<div className="card-header bg-white"><strong>{section.label ?? subjectName}</strong></div>
<div className="card-body">
{!entry ? (
<div className="text-muted">No entry submitted for this subject this week.</div>
) : (
<>
<div className="mb-2">
<ClassProgressUnitDisplay unitTitle={entry.unit_title} isQuran={subjectName === 'Quran/Arabic'} />
</div>
<div className="mb-3"><strong>Activities</strong><div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>{entry.covered || '-'}</div></div>
<div className="mb-3"><strong>{subjectName === 'Quran/Arabic' ? 'Arabic Practice / Homework' : 'Assigned Homework'}:</strong><div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>{entry.homework || '-'}</div></div>
{(entry.attachments ?? []).length > 0 ? (
<div className="mt-3">
<strong>Attachments:</strong>
<div className="d-flex flex-column gap-2 mt-2">
{(entry.attachments ?? []).map((attachment) => (
<a key={attachment.id ?? attachment.download_url} className="btn btn-sm btn-outline-secondary text-start" href={attachment.download_url ?? '#'} target="_blank" rel="noreferrer">
{attachment.name ?? 'Attachment'}
</a>
))}
</div>
</div>
) : null}
</>
)}
</div>
</div>
)
})}
</div>
<div className="col-lg-4">
<div className="card shadow-sm mb-3">
<div className="card-header bg-white"><strong>Summary</strong></div>
<div className="card-body">
<div className="mb-2"><strong>Teacher:</strong> {report.teacher_name ?? '-'}</div>
<div className="mb-2"><strong>Submitted:</strong> {formatDate(report.created_at, true)}</div>
<div><strong>Status:</strong> {report.status_label ?? 'Unknown'}</div>
</div>
</div>
{(report.flags ?? []).length > 0 ? (
<div className="card shadow-sm mb-3">
<div className="card-header bg-white"><strong>Checklist</strong></div>
<div className="card-body d-flex flex-wrap gap-2">
{(report.flags ?? []).map((flag) => <span key={flag} className="badge bg-light text-dark border">{flag.replace(/_/g, ' ')}</span>)}
</div>
</div>
) : null}
{attachmentList.length > 0 ? (
<div className="card shadow-sm">
<div className="card-header bg-white"><strong>Attachments</strong></div>
<div className="card-body">
{attachmentList.map(({ subject, attachment }, index) => (
<a key={`${attachment.id ?? index}`} className="btn btn-outline-primary w-100 text-start mb-2" href={attachment.download_url ?? '#'} target="_blank" rel="noreferrer">
{`${subject}${attachment.name ?? 'Attachment'}`}
</a>
))}
</div>
</div>
) : null}
</div>
</div>
</>
) : null}
</PageShell>
)
}
export function AdminStudentScoreCardPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Array<Record<string, unknown>>>([])
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
const [selectedStudent, setSelectedStudent] = useState<{ firstname?: string | null; lastname?: string | null; school_id?: string | null } | null>(null)
const [rows, setRows] = useState<StudentScoreCardRow[]>([])
const [error, setError] = useState<string | null>(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 (
<PageShell title="Student Score Card (Admin)">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card shadow-sm mb-3">
<div className="card-body">
<form onSubmit={(e) => void runSearch(e)}>
<div className="row g-2 justify-content-center">
<div className="col-12 col-md-8 col-lg-6">
<div className="input-group">
<span className="input-group-text">Search Student</span>
<input className="form-control" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Type School ID or Student Name" />
<button className="btn btn-outline-secondary" type="button" onClick={() => setQuery('')}>Clear</button>
</div>
<small className="text-muted">Searches all grades.</small>
</div>
</div>
<button type="submit" className="d-none">Search</button>
</form>
</div>
</div>
{query.trim() && results.length === 0 ? <div className="alert alert-warning">No students found.</div> : null}
{results.length > 0 ? (
<div className="card shadow-sm mb-4">
<div className="card-header d-flex justify-content-between align-items-center"><strong>Results</strong><span className="text-muted small">Showing matching students</span></div>
<div className="card-body table-responsive pt-2">
<table className="table table-striped table-hover align-middle">
<thead className="table-light"><tr><th>School ID</th><th>Student</th><th></th></tr></thead>
<tbody>
{results.map((row) => (
<tr key={String(row.id ?? '')}>
<td>{String(row.school_id ?? '')}</td>
<td>{`${String(row.firstname ?? '')} ${String(row.lastname ?? '')}`.trim()}</td>
<td className="text-end">
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void openScoreCard(Number(row.id))}>Open Score Card</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
{selectedStudentId && selectedStudent ? (
<div className="card shadow-sm">
<div className="card-header">
<strong>{`${selectedStudent.firstname ?? ''} ${selectedStudent.lastname ?? ''}`.trim()}</strong>
<span className="text-muted ms-2">{selectedStudent.school_id ?? ''}</span>
</div>
<div className="card-body table-responsive">
<table className="table table-bordered table-sm align-middle mb-0">
<thead className="table-light">
<tr>
<th>School Year</th>
<th>Semester</th>
<th>Class</th>
<th>Homework</th>
<th>Project</th>
<th>Participation</th>
<th>Quiz</th>
<th>Test</th>
<th>Attendance</th>
<th>PTAP</th>
<th>Midterm</th>
<th>Semester</th>
<th>Year Avg</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? <tr><td colSpan={13} className="text-muted">No score rows found.</td></tr> : rows.map((row, index) => (
<tr key={`${row.school_year}-${row.semester}-${index}`}>
<td>{row.school_year ?? '—'}</td>
<td>{row.semester ?? '—'}</td>
<td>{row.class_section_name ?? '—'}</td>
<td>{row.homework_avg ?? '—'}</td>
<td>{row.project_avg ?? '—'}</td>
<td>{row.participation_score ?? '—'}</td>
<td>{row.quiz_avg ?? '—'}</td>
<td>{row.test_avg ?? '—'}</td>
<td>{row.attendance_score ?? '—'}</td>
<td>{row.ptap_score ?? '—'}</td>
<td>{row.midterm_exam_score ?? '—'}</td>
<td>{row.semester_score ?? '—'}</td>
<td>{row.year_score ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</PageShell>
)
}
export function AdminEmergencyContactIndexPage() {
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
const [error, setError] = useState<string | null>(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 (
<PageShell title="Emergency Contact Information">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead className="table-dark">
<tr><th>Parent Name</th><th>Students</th><th>Contact Name</th><th>Relation</th><th>Phone</th><th>Actions</th></tr>
</thead>
<tbody>
{groups.flatMap((group) =>
(group.contacts ?? []).map((contact) => (
<tr key={contact.id}>
<td>{group.parent_name ?? '—'}</td>
<td>{(group.students ?? []).map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()).join(', ') || '—'}</td>
<td>{contact.name ?? '—'}</td>
<td>{contact.relation ?? '—'}</td>
<td>{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'}</td>
<td className="d-flex gap-2">
<Link className="btn btn-sm btn-primary" to={`/app/administrator/emergency-contacts/${contact.id}/edit?parent_id=${group.parent_id}`}>Edit</Link>
<button className="btn btn-sm btn-danger" type="button" onClick={() => void removeContact(contact.id, group.parent_id)}>Delete</button>
</td>
</tr>
)),
)}
{groups.length === 0 ? <tr><td colSpan={6} className="text-muted">No emergency contacts found.</td></tr> : null}
</tbody>
</table>
</div>
</PageShell>
)
}
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<string | null>(null)
const [message, setMessage] = useState<string | null>(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 (
<PageShell title="Edit Emergency Contact">
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="card shadow-sm" style={{ maxWidth: 720 }}>
<div className="card-body">
<form onSubmit={(e) => void onSubmit(e)}>
<div className="mb-3"><label className="form-label">Name</label><input className="form-control" value={form.name} onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))} required /></div>
<div className="mb-3"><label className="form-label">Phone</label><input className="form-control" value={form.cellphone} onChange={(e) => setForm((current) => ({ ...current, cellphone: e.target.value }))} required /></div>
<div className="mb-3"><label className="form-label">Email</label><input type="email" className="form-control" value={form.email} onChange={(e) => setForm((current) => ({ ...current, email: e.target.value }))} /></div>
<div className="mb-3"><label className="form-label">Relation</label><input className="form-control" value={form.relation} onChange={(e) => setForm((current) => ({ ...current, relation: e.target.value }))} /></div>
<div className="d-flex gap-2">
<button className="btn btn-primary" type="submit">Update</button>
<Link className="btn btn-secondary" to="/app/administrator/emergency-contacts">Cancel</Link>
</div>
</form>
</div>
</div>
</PageShell>
)
}
File diff suppressed because it is too large Load Diff
+188 -69
View File
@@ -1,29 +1,39 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchDashboardRoute } from '../api/session'
import { useEffect, useState, type FormEvent } from 'react'
import {
fetchAdministratorDashboardMetrics,
searchAdministratorDashboard,
} from '../api/session'
import { useAuth } from '../auth/AuthProvider'
import type {
AdministratorDashboardMetricsResponse,
AdministratorDashboardSearchResponse,
} from '../api/types'
export function AppHome() {
const { user } = useAuth()
const [route, setRoute] = useState<string | null>(null)
const [roles, setRoles] = useState<string[]>([])
const [metrics, setMetrics] = useState<AdministratorDashboardMetricsResponse | null>(null)
const [query, setQuery] = useState('')
const [search, setSearch] = useState<AdministratorDashboardSearchResponse | null>(null)
const [loadingMetrics, setLoadingMetrics] = useState(true)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoadingMetrics(true)
try {
const res = await fetchDashboardRoute()
const d = res.data?.dashboard
if (!cancelled && d) {
setRoute(d.route)
setRoles(d.roles ?? [])
const data = await fetchAdministratorDashboardMetrics()
if (!cancelled) {
setMetrics(data)
setError(null)
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unable to load dashboard.')
}
} finally {
if (!cancelled) setLoadingMetrics(false)
}
})()
return () => {
@@ -31,74 +41,183 @@ export function AppHome() {
}
}, [])
const roleKeys = user?.roles ? Object.keys(user.roles).filter((k) => user.roles[k]) : []
async function onSearch(event: FormEvent) {
event.preventDefault()
setSearching(true)
setError(null)
try {
const results = await searchAdministratorDashboard(query)
setSearch(results)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to search dashboard records.')
} finally {
setSearching(false)
}
}
const counts = metrics?.counts ?? {}
const statCards = [
{ key: 'students', title: 'Students', icon: 'bi bi-mortarboard', value: counts.students ?? 0, tone: 'primary' },
{ key: 'teachers', title: 'Teachers', icon: 'bi bi-person-video3', value: counts.teachers ?? 0, tone: 'success' },
{ key: 'teacherAssistants', title: 'Teacher Assistants', icon: 'bi bi-people', value: counts.teacherAssistants ?? 0, tone: 'warning' },
{ key: 'admins', title: 'Admins', icon: 'bi bi-person-badge', value: counts.admins ?? 0, tone: 'info' },
{ key: 'parents', title: 'Parents', icon: 'bi bi-people-fill', value: counts.parents ?? 0, tone: 'light' },
] as const
return (
<div>
<h2 className="h4 mb-3">Welcome</h2>
<p className="lead mb-4">
Signed in as <strong>{user?.name}</strong>.
</p>
<div className="container-fluid admin-dashboard">
<section className="mb-4">
<h2>Welcome, {user?.name || 'Administrator'}</h2>
<p>Here is an overview of your site's activity.</p>
</section>
<div className="row g-3 mb-4">
<div className="col-md-6">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<h3 className="h6 text-muted">Roles (from login)</h3>
<section className="mb-5">
<h2 className="mb-3">Search</h2>
{error ? <div className="alert alert-danger">{error}</div> : null}
<form className="mb-3" onSubmit={onSearch}>
<div className="input-group">
<input
type="text"
className="form-control"
placeholder="Search name, email, phone, school ID, badge code..."
value={query}
onChange={(event) => setQuery(event.target.value)}
required
/>
<button className="btn btn-outline-secondary" type="submit" disabled={searching}>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
<div className="form-text">Supports Users, Parents, Staff, Students, and Emergency Contacts.</div>
</form>
{search?.query ? (
<div className="mb-2 small text-muted">
Showing results for: <strong>{search.query}</strong>
</div>
) : null}
{search && (
<div className="row g-3">
<SearchTable title="Users" icon="bi bi-people" columns={['Name', 'Email', 'Phone', 'School ID']} rows={search.results.users ?? []} renderRow={(row) => [
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '',
stringValue(row.email),
stringValue(row.cellphone),
stringValue(row.school_id),
]} />
<SearchTable title="Students" icon="bi bi-mortarboard" columns={['School ID', 'Name', 'Gender', 'DOB']} rows={search.results.students ?? []} renderRow={(row) => [
stringValue(row.school_id),
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '',
stringValue(row.gender),
stringValue(row.dob),
]} />
<SearchTable title="Parents" icon="bi bi-people-fill" columns={['First Parent ID', 'Second Parent', 'Email', 'Phone']} rows={search.results.parents ?? []} renderRow={(row) => [
stringValue(row.firstparent_id),
`${stringValue(row.secondparent_firstname)} ${stringValue(row.secondparent_lastname)}`.trim() || '',
stringValue(row.secondparent_email),
stringValue(row.secondparent_phone),
]} />
<SearchTable title="Staff" icon="bi bi-person-badge" columns={['Role', 'Name', 'Email', 'Phone', 'Status']} rows={search.results.staff ?? []} renderRow={(row) => [
stringValue(row.role_name),
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '',
stringValue(row.email),
stringValue(row.phone),
stringValue(row.active_role),
]} />
<SearchTable title="Emergency Contacts" icon="bi bi-telephone-outbound" columns={['Name', 'Relation', 'Phone', 'Email']} rows={search.results.emergency_contacts ?? []} renderRow={(row) => [
stringValue(row.emergency_contact_name),
stringValue(row.relation),
stringValue(row.cellphone),
stringValue(row.email),
]} fullWidth />
</div>
)}
</section>
<section className="mb-5">
<h2 className="mb-4 text-center">Statistics</h2>
{loadingMetrics ? (
<p className="text-muted text-center">Loading...</p>
) : (
<div className="stats-row">
{statCards.map((item) => (
<div key={item.key} className={`stat-circle circle-${item.tone}`}>
<div className="stat-icon"><i className={item.icon} aria-hidden /></div>
<div className="stat-value">{item.value}</div>
<div className="stat-title">{item.title}</div>
</div>
))}
</div>
)}
</section>
<section>
<h2 className="mb-3">Recent Activity</h2>
<div className="card border-0 shadow-sm">
<div className="card-body">
{(metrics?.recentActivities ?? []).length === 0 ? (
<p className="text-muted mb-0">No recent login activity.</p>
) : (
<ul className="mb-0">
{roleKeys.length ? (
roleKeys.map((r) => <li key={r}>{r}</li>)
) : (
<li className="text-muted">None reported</li>
)}
{(metrics?.recentActivities ?? []).map((item, index) => (
<li key={`${item.email ?? 'activity'}-${index}`}>
{item.email ?? 'Unknown user'} <span className="text-muted">· {item.login_time ?? ''}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<div className="col-md-6">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<h3 className="h6 text-muted">Dashboard route (API)</h3>
{error ? (
<p className="text-danger small mb-0">{error}</p>
) : (
<>
<p className="mb-1">
<code>{route ?? '—'}</code>
</p>
<p className="small text-muted mb-0">
Resolved roles: {roles.length ? roles.join(', ') : '—'}
</p>
</>
)}
</div>
</div>
</div>
</div>
</section>
</div>
)
}
<div className="border rounded p-3 bg-white">
<h3 className="h6">Shortcuts</h3>
<ul className="mb-0">
<li>
<Link to="/app/parent/home">Parent Dashboard</Link> (parity with
CodeIgniter <code>parent/parent_dashboard</code>)
</li>
<li>
<Link to="/app/parent/attendance">Parent Attendance</Link> (parity
with CodeIgniter <code>Views/parent/attendance.php</code>)
</li>
<li>
<Link to="/app/parent/home">Parent All pages</Link> (navigation for
every view under <code>Views/parent/</code>)
</li>
<li>
<Link to="/docs">Public API docs (Swagger)</Link>
</li>
<li>
<code>app/Views</code> screen as an <code>/app/</code> parity route
</li>
</ul>
function SearchTable({
title,
icon,
columns,
rows,
renderRow,
fullWidth = false,
}: {
title: string
icon: string
columns: string[]
rows: Record<string, unknown>[]
renderRow: (row: Record<string, unknown>) => string[]
fullWidth?: boolean
}) {
return (
<div className={`col-12 ${fullWidth ? '' : 'col-lg-6'}`}>
<div className="card">
<div className="card-header"><i className={`${icon} me-1`} aria-hidden />{title}</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-sm mb-0">
<thead>
<tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={columns.length} className="text-muted">No {title.toLowerCase()} found.</td></tr>
) : (
rows.map((row, index) => (
<tr key={String(row.id ?? `${title}-${index}`)}>
{renderRow(row).map((cell, cellIndex) => <td key={`${index}-${cellIndex}`}>{cell || ''}</td>)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
function stringValue(value: unknown) {
return value == null || value === '' ? '' : String(value)
}
+445
View File
@@ -0,0 +1,445 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import {
fetchCompetitionScoresDetail,
fetchCompetitionScoresIndex,
fetchPublishedCompetition,
fetchPublishedCompetitions,
saveCompetitionScores,
} from '../api/session'
import type {
CompetitionScoreStudentRow,
CompetitionScoresEditResponse,
CompetitionScoresIndexCompetitionRow,
PublicCompetitionRow,
PublicCompetitionWinnerRow,
} from '../api/types'
function PageShell({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="container-fluid py-3">
<div className="mb-4">
<h2 className="h4 mb-1">{title}</h2>
</div>
{children}
</div>
)
}
function formatDate(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
}
function competitionLabel(row?: PublicCompetitionRow | null) {
return row?.title ?? `Competition #${row?.id ?? ''}`
}
function noteCard(children: React.ReactNode) {
return <div className="alert alert-warning">{children}</div>
}
export function CompetitionWinnersIndexPage() {
const [rows, setRows] = useState<PublicCompetitionRow[]>([])
const [teacherRows, setTeacherRows] = useState<CompetitionScoresIndexCompetitionRow[]>([])
const [activeClassName, setActiveClassName] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
;(async () => {
try {
const [published, teacher] = await Promise.allSettled([
fetchPublishedCompetitions(),
fetchCompetitionScoresIndex(),
])
if (published.status === 'fulfilled') {
setRows(published.value.data?.competitions ?? [])
}
if (teacher.status === 'fulfilled') {
setTeacherRows(teacher.value.competitions ?? [])
setActiveClassName(teacher.value.activeClassName ?? null)
}
if (published.status === 'rejected' && teacher.status === 'rejected') {
setError('Unable to load competition winners data.')
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition winners data.')
}
})()
}, [])
return (
<PageShell title="Competition Winners">
{noteCard(
<>
The current API exposes published winners data and teacher score entry. I could not find routed admin APIs for
create, settings, publish, lock, unlock, export quiz, or recognition PDF.
</>,
)}
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card shadow-sm mb-4">
<div className="card-header">Published Competitions</div>
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle mb-0">
<thead className="table-light">
<tr>
<th>ID</th>
<th>Title</th>
<th>Semester</th>
<th>School Year</th>
<th>Published</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={6} className="text-muted">No published competitions found.</td></tr>
) : rows.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{competitionLabel(row)}</td>
<td>{row.semester ?? '—'}</td>
<td>{row.school_year ?? '—'}</td>
<td>{formatDate(row.published_at)}</td>
<td className="d-flex gap-2 flex-wrap">
<Link className="btn btn-sm btn-outline-secondary" to={`/app/admin/competition-winners/${row.id}/preview`}>Preview</Link>
<Link className="btn btn-sm btn-outline-secondary" to={`/app/admin/competition-winners/${row.id}/winners`}>Winners</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card shadow-sm">
<div className="card-header">Teacher Score Entry Competitions{activeClassName ? ` - ${activeClassName}` : ''}</div>
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle mb-0">
<thead className="table-light">
<tr>
<th>ID</th>
<th>Title</th>
<th>Published</th>
<th>Locked</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{teacherRows.length === 0 ? (
<tr><td colSpan={5} className="text-muted">No teacher competition score entries available for this account.</td></tr>
) : teacherRows.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.title ?? `Competition #${row.id}`}</td>
<td>{row.is_published ? 'Yes' : 'No'}</td>
<td>{row.is_locked ? 'Yes' : 'No'}</td>
<td>
<Link className="btn btn-sm btn-outline-primary" to={`/app/admin/competition-winners/${row.id}`}>Enter Scores</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</PageShell>
)
}
export function CompetitionWinnerFormPage() {
const { id } = useParams()
return (
<PageShell title={id ? 'Edit Competition' : 'Create Competition'}>
{noteCard(
<>
This screen depends on admin competition management endpoints that are not currently routed in the Laravel API.
The SPA can show published winners and teacher score entry, but it cannot safely create or update competition settings
without those endpoints.
</>,
)}
<div className="card shadow-sm">
<div className="card-body">
<div className="row g-3">
<div className="col-md-8"><label className="form-label">Title</label><input className="form-control" disabled value="" /></div>
<div className="col-md-4"><label className="form-label">Class Section</label><input className="form-control" disabled value="" /></div>
<div className="col-md-3"><label className="form-label">Semester</label><input className="form-control" disabled value="" /></div>
<div className="col-md-3"><label className="form-label">School Year</label><input className="form-control" disabled value="" /></div>
<div className="col-md-3"><label className="form-label">Start Date</label><input className="form-control" disabled value="" /></div>
<div className="col-md-3"><label className="form-label">End Date</label><input className="form-control" disabled value="" /></div>
</div>
</div>
</div>
</PageShell>
)
}
export function CompetitionWinnerScoresPage() {
const { id } = useParams()
const competitionId = Number(id)
const [searchParams, setSearchParams] = useSearchParams()
const selectedClassId = Number(searchParams.get('class_section_id') ?? 0) || null
const [data, setData] = useState<CompetitionScoresEditResponse | null>(null)
const [scores, setScores] = useState<Record<string, number | string>>({})
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function load(classSectionId = selectedClassId) {
try {
const response = await fetchCompetitionScoresDetail(competitionId, classSectionId)
setData(response)
setScores(response.scoreMap ?? {})
setError(response.ok ? null : response.message ?? 'Unable to load competition scores.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition scores.')
}
}
useEffect(() => {
if (!Number.isFinite(competitionId) || competitionId <= 0) return
void load()
}, [competitionId, selectedClassId])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!competitionId) return
try {
const result = await saveCompetitionScores(competitionId, {
class_section_id: data?.classSectionId ?? selectedClassId,
scores,
})
setMessage(result.message ?? 'Scores saved.')
if (result.ok) {
await load()
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to save scores.')
}
}
return (
<PageShell title="Enter Scores">
{noteCard(
<>
This page uses the teacher-facing `competition-scores` API. It works only when the logged-in account is assigned to the
target class section.
</>,
)}
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
{data?.competition ? (
<>
<div className="mb-3">
<div><strong>Competition:</strong> {competitionLabel(data.competition)}</div>
<div><strong>Class Section:</strong> {data.classSectionName ?? '—'}</div>
<div>
<strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong> {data.questionCount ?? '—'}
</div>
</div>
<form onSubmit={(e) => void onSubmit(e)}>
{data.classSelectionLocked === false ? (
<div className="mb-3" style={{ maxWidth: 360 }}>
<label className="form-label">Class Section ID</label>
<input
className="form-control"
value={String(selectedClassId ?? data.classSectionId ?? '')}
onChange={(e) => setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})}
/>
</div>
) : null}
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle">
<thead className="table-light">
<tr>
<th>School ID</th>
<th>Student</th>
<th>Score{data.questionCount ? ` (max ${data.questionCount})` : ''}</th>
</tr>
</thead>
<tbody>
{(data.students ?? []).length === 0 ? (
<tr><td colSpan={3} className="text-muted">No students loaded for this competition.</td></tr>
) : (data.students ?? []).map((student: CompetitionScoreStudentRow) => {
const studentId = Number(student.id ?? student.student_id ?? 0)
const name = `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim() || `Student #${studentId}`
return (
<tr key={studentId}>
<td>{student.school_id ?? studentId}</td>
<td>{name}</td>
<td>
<input
className="form-control form-control-sm"
type="number"
step="1"
min="0"
max={data.questionCount ?? undefined}
disabled={Boolean(data.isLocked)}
value={String(scores[String(studentId)] ?? '')}
onChange={(e) => setScores((current) => ({ ...current, [String(studentId)]: e.target.value }))}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="d-flex gap-2">
<button className="btn btn-primary" disabled={Boolean(data.isLocked)}>Save Scores</button>
<Link className="btn btn-outline-secondary" to={`/app/admin/competition-winners/${competitionId}/preview`}>Preview</Link>
</div>
</form>
</>
) : (
<p className="text-muted mb-0">No competition data loaded.</p>
)}
</PageShell>
)
}
function usePublishedCompetition(id: number) {
const [competition, setCompetition] = useState<PublicCompetitionRow | null>(null)
const [winnersByClass, setWinnersByClass] = useState<Record<string, PublicCompetitionWinnerRow[]>>({})
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
const [questionCounts, setQuestionCounts] = useState<Record<string, number>>({})
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
;(async () => {
try {
const response = await fetchPublishedCompetition(id)
if (!response.status || !response.data) {
setError(response.message ?? 'Competition not found.')
return
}
setCompetition(response.data.competition)
setWinnersByClass(response.data.winners_by_class ?? {})
setSectionMap(response.data.section_map ?? {})
setQuestionCounts(response.data.question_counts ?? {})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners.')
}
})()
}, [id])
return { competition, winnersByClass, sectionMap, questionCounts, error }
}
export function CompetitionWinnerPreviewPage() {
const { id } = useParams()
const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, error } = usePublishedCompetition(competitionId)
return (
<PageShell title="Preview Winners">
{noteCard('This preview is backed by the public published-winners API. Unpublished preview data is not exposed by the current backend.')}
{error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? (
<>
<div className="mb-3">
<div><strong>Competition:</strong> {competitionLabel(competition)}</div>
<div><strong>School Year:</strong> {competition.school_year ?? '—'}</div>
</div>
{Object.keys(winnersByClass).length === 0 ? (
<div className="alert alert-warning">No published winners found for this competition.</div>
) : Object.entries(winnersByClass).map(([classId, rows]) => (
<div key={classId} className="mb-4">
<h5 className="mt-4">Top Winners - {sectionMap[classId] ?? `Class ${classId}`}</h5>
<table className="table table-bordered table-sm">
<thead className="table-light">
<tr><th>Rank</th><th>Student</th><th>Score</th><th>Prize</th></tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${classId}-${index}`}>
<td>{row.rank ?? index + 1}</td>
<td>{row.name ?? (`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `Student #${row.student_id ?? ''}`)}</td>
<td>{row.score ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</>
) : null}
</PageShell>
)
}
export function CompetitionWinnerWinnersPage() {
const { id } = useParams()
const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, questionCounts, error } = usePublishedCompetition(competitionId)
const flatRows = useMemo(
() =>
Object.entries(winnersByClass).flatMap(([classId, rows]) =>
rows.map((row) => ({ ...row, _classId: classId })),
),
[winnersByClass],
)
const totalPrize = flatRows.reduce((sum, row) => sum + Number(row.prize_amount ?? 0), 0)
return (
<PageShell title="School Winners">
{noteCard('This page uses the public published-winners API. The admin-only recognition PDF endpoint is not currently exposed in the Laravel API.')}
{error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? (
<>
<div className="mb-3">
<div><strong>Competition:</strong> {competitionLabel(competition)}</div>
<div><strong>School Year:</strong> {competition.school_year ?? '—'}</div>
<div><strong>Published:</strong> {competition.is_published ? 'Yes' : 'No'}</div>
</div>
{flatRows.length === 0 ? (
<div className="alert alert-warning">No published winners yet.</div>
) : (
<div className="table-responsive">
<table className="table table-bordered table-sm">
<thead className="table-light">
<tr>
<th>Class</th>
<th>Student</th>
<th>Rank</th>
<th>Score</th>
<th>Question Count</th>
<th>Prize</th>
</tr>
</thead>
<tbody>
{flatRows.map((row, index) => (
<tr key={`${row._classId}-${index}`}>
<td>{sectionMap[String(row._classId)] ?? `Class ${row._classId}`}</td>
<td>{row.name ?? (`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `Student #${row.student_id ?? ''}`)}</td>
<td>{row.rank ?? '—'}</td>
<td>{row.score ?? '—'}</td>
<td>{questionCounts[String(row._classId)] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th colSpan={5} className="text-end">Total Prizes</th>
<th>{totalPrize.toFixed(2)}</th>
</tr>
</tfoot>
</table>
</div>
)}
</>
) : null}
</PageShell>
)
}
+25 -26
View File
@@ -8,22 +8,19 @@ export function RoleSelectPage() {
const { user, roles, selectedRole, setSelectedRole } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [pickedRole, setPickedRole] = useState<string | null>(selectedRole)
const [busyRole, setBusyRole] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
async function continueLogin() {
if (!pickedRole) {
setError('Please select a role to continue.')
return
}
async function continueLogin(nextRole: string) {
setBusy(true)
setBusyRole(nextRole)
setError(null)
setSelectedRole(pickedRole)
setSelectedRole(nextRole)
let target = from.startsWith('/app') ? from : '/app/home'
if (pickedRole.toLowerCase() === 'parent' && target === '/app/home') {
if (nextRole.toLowerCase() === 'parent' && target === '/app/home') {
target = '/app/parent/home'
}
try {
@@ -39,41 +36,43 @@ export function RoleSelectPage() {
}
return (
<div className="container py-4">
<div className="card border-0 shadow-sm mx-auto" style={{ maxWidth: 640 }}>
<div className="card-body p-4">
<h2 className="h4 mb-2">Select role</h2>
<p className="text-muted mb-3">
{user?.name ? `Signed in as ${user.name}.` : 'Signed in successfully.'} Choose a role
to continue.
<div className="container mt-5">
<div className="card shadow-sm border-0 mx-auto" style={{ maxWidth: 720 }}>
<div className="card-header bg-primary text-white">
<h4 className="mb-0">Select Account Type</h4>
</div>
<div className="card-body">
<p className="mb-4">
{user?.name ? `${user.name}, y` : 'Y'}ou have multiple roles. Please select the account
you&apos;d like to log into:
</p>
<div className="list-group mb-3">
<div className="d-flex flex-wrap gap-3">
{roles.map((role) => (
<button
key={role}
type="button"
className={`list-group-item list-group-item-action text-start ${
pickedRole === role ? 'active' : ''
className={`btn ${
selectedRole === role ? 'btn-primary' : 'btn-outline-primary'
}`}
onClick={() => setPickedRole(role)}
onClick={() => {
void continueLogin(role)
}}
disabled={busy}
>
{role}
{busy && busyRole === role
? 'Opening...'
: role.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())}
</button>
))}
</div>
{error ? (
<div className="alert alert-danger py-2" role="alert">
<div className="alert alert-danger py-2 mt-3" role="alert">
{error}
</div>
) : null}
<div className="d-grid">
<button type="button" className="btn btn-success" onClick={continueLogin} disabled={busy}>
{busy ? 'Continuing…' : 'Continue'}
</button>
</div>
</div>
</div>
</div>