add admin pages
This commit is contained in:
+222
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 < 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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
|
||||
Reference in New Issue
Block a user