add admin pages
This commit is contained in:
+222
@@ -38,6 +38,186 @@ const CiPlaceholderPage = lazy(() =>
|
|||||||
const NavBuilderPage = lazy(() =>
|
const NavBuilderPage = lazy(() =>
|
||||||
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
|
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(() =>
|
const ParentHomePage = lazy(() =>
|
||||||
import('./pages/parent/ParentHomePage').then((m) => ({ default: m.ParentHomePage })),
|
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 index element={<Navigate to="home" replace />} />
|
||||||
<Route path="select-role" element={<RoleSelectPage />} />
|
<Route path="select-role" element={<RoleSelectPage />} />
|
||||||
<Route path="home" element={<AppHome />} />
|
<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 path="nav-builder" element={<NavBuilderPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
+818
-2
@@ -1,11 +1,25 @@
|
|||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { apiFetch, getStoredToken } from './http'
|
import { apiFetch, getStoredToken } from './http'
|
||||||
import type {
|
import type {
|
||||||
|
AdministratorAbsenceFormResponse,
|
||||||
|
AdministratorAbsenceSubmitResponse,
|
||||||
|
AdministratorAdminAttendanceResponse,
|
||||||
|
AdministratorDailyAttendanceResponse,
|
||||||
|
AdministratorDashboardMetricsResponse,
|
||||||
|
AdministratorDashboardSearchResponse,
|
||||||
ApiEnvelope,
|
ApiEnvelope,
|
||||||
|
BroadcastEmailOptionsResponse,
|
||||||
|
BroadcastEmailSendResponse,
|
||||||
|
ClassSectionShowResponse,
|
||||||
|
ClassSectionsResponse,
|
||||||
DashboardPayload,
|
DashboardPayload,
|
||||||
|
FamilyAdminIndexResponse,
|
||||||
|
GradingOverviewResponse,
|
||||||
|
IpBansResponse,
|
||||||
LoginFailure,
|
LoginFailure,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
LoginSuccess,
|
LoginSuccess,
|
||||||
|
LateSlipLogsResponse,
|
||||||
NavBuilderDataResponse,
|
NavBuilderDataResponse,
|
||||||
NavBuilderDeleteResponse,
|
NavBuilderDeleteResponse,
|
||||||
NavBuilderSaveResponse,
|
NavBuilderSaveResponse,
|
||||||
@@ -19,16 +33,41 @@ import type {
|
|||||||
ParentRegistrationOverviewResponse,
|
ParentRegistrationOverviewResponse,
|
||||||
ParentEmergencyContactsResponse,
|
ParentEmergencyContactsResponse,
|
||||||
ParentAttendanceReportsListResponse,
|
ParentAttendanceReportsListResponse,
|
||||||
|
SchoolCalendarListResponse,
|
||||||
|
SchoolCalendarOptionsResponse,
|
||||||
|
SchoolCalendarShowResponse,
|
||||||
ParentAuthorizedUsersApiResponse,
|
ParentAuthorizedUsersApiResponse,
|
||||||
ParentMessagesResponse,
|
ParentMessagesResponse,
|
||||||
|
NotificationAlertsResponse,
|
||||||
|
PaypalTransactionsResponse,
|
||||||
|
PrintNotificationRecipientsResponse,
|
||||||
SchoolCalendarEventsResponse,
|
SchoolCalendarEventsResponse,
|
||||||
|
StudentAssignmentsResponse,
|
||||||
StudentScoresResponse,
|
StudentScoresResponse,
|
||||||
ReportCardAcknowledgementResponse,
|
ReportCardAcknowledgementResponse,
|
||||||
ReportCardMetaResponse,
|
ReportCardMetaResponse,
|
||||||
|
RemovedStudentsResponse,
|
||||||
ClassProgressGroupsResponse,
|
ClassProgressGroupsResponse,
|
||||||
|
ClassProgressMetaResponse,
|
||||||
ClassProgressShowResponse,
|
ClassProgressShowResponse,
|
||||||
ParentProfileRecord,
|
ParentProfileRecord,
|
||||||
ContactSubmitResponse,
|
ContactSubmitResponse,
|
||||||
|
CompetitionScoresEditResponse,
|
||||||
|
CompetitionScoresIndexResponse,
|
||||||
|
ExamDraftAdminResponse,
|
||||||
|
PublicCompetitionIndexResponse,
|
||||||
|
PublicCompetitionShowResponse,
|
||||||
|
StudentDirectoryResponse,
|
||||||
|
StudentScoreCardResponse,
|
||||||
|
StudentEmergencyContactResponse,
|
||||||
|
StudentParentResponse,
|
||||||
|
StudentPromotionTotalsResponse,
|
||||||
|
SubjectCurriculumResponse,
|
||||||
|
TeacherClassAssignmentsResponse,
|
||||||
|
TeacherSubmissionsResponse,
|
||||||
|
EmergencyContactGroupsResponse,
|
||||||
|
EmergencyContactShowResponse,
|
||||||
|
UsersResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export async function loginRequest(
|
export async function loginRequest(
|
||||||
@@ -57,6 +96,702 @@ export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayloa
|
|||||||
return apiFetch<ApiEnvelope<DashboardPayload>>('/api/v1/dashboard/route')
|
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> {
|
export async function fetchNavMenu(): Promise<NavMenuResponse> {
|
||||||
return apiFetch<NavMenuResponse>('/api/v1/nav-builder/menu')
|
return apiFetch<NavMenuResponse>('/api/v1/nav-builder/menu')
|
||||||
}
|
}
|
||||||
@@ -208,14 +943,95 @@ export async function fetchReportCardAcknowledgement(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchClassProgressGroups(): Promise<ClassProgressGroupsResponse> {
|
export async function fetchClassProgressGroups(params?: {
|
||||||
return apiFetch<ClassProgressGroupsResponse>('/api/v1/class-progress?group_by_week=1')
|
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> {
|
export async function fetchClassProgressDetail(id: number): Promise<ClassProgressShowResponse> {
|
||||||
return apiFetch<ClassProgressShowResponse>(`/api/v1/class-progress/${id}`)
|
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(
|
export async function updateParentProfile(
|
||||||
payload: Required<
|
payload: Required<
|
||||||
Pick<
|
Pick<
|
||||||
|
|||||||
+838
-2
@@ -87,6 +87,120 @@ export type NavBuilderDataResponse = ApiEnvelope<{ data: NavBuilderData }>
|
|||||||
export type NavBuilderSaveResponse = ApiEnvelope<{ id: number }>
|
export type NavBuilderSaveResponse = ApiEnvelope<{ id: number }>
|
||||||
export type NavBuilderDeleteResponse = ApiEnvelope<null>
|
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 = {
|
export type ParentAttendanceRow = {
|
||||||
firstname: string
|
firstname: string
|
||||||
lastname: string
|
lastname: string
|
||||||
@@ -158,6 +272,646 @@ export type ContactSubmitResponse = {
|
|||||||
message?: string
|
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). */
|
/** Loose JSON payloads for complex parent dashboards (events, registration, attendance report form). */
|
||||||
export type ParentEventsResponse = Record<string, unknown>
|
export type ParentEventsResponse = Record<string, unknown>
|
||||||
export type ParentRegistrationResponse = Record<string, unknown>
|
export type ParentRegistrationResponse = Record<string, unknown>
|
||||||
@@ -259,6 +1013,36 @@ export type StudentScoresResponse = {
|
|||||||
rows: StudentScoreRow[]
|
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 = {
|
export type ReportCardStudentMetaRow = {
|
||||||
id: number
|
id: number
|
||||||
firstname: string
|
firstname: string
|
||||||
@@ -289,6 +1073,7 @@ export type ClassProgressReportRow = {
|
|||||||
id: number
|
id: number
|
||||||
teacher_name?: string | null
|
teacher_name?: string | null
|
||||||
class_section_name?: string | null
|
class_section_name?: string | null
|
||||||
|
class_section_id?: number | null
|
||||||
subject?: string | null
|
subject?: string | null
|
||||||
unit_title?: string | null
|
unit_title?: string | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
@@ -296,9 +1081,12 @@ export type ClassProgressReportRow = {
|
|||||||
covered?: string | null
|
covered?: string | null
|
||||||
homework?: string | null
|
homework?: string | null
|
||||||
support_needed?: string | null
|
support_needed?: string | null
|
||||||
|
flags?: string[]
|
||||||
week_start?: string | null
|
week_start?: string | null
|
||||||
week_end?: 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 = {
|
export type ClassProgressGroupRow = {
|
||||||
@@ -316,10 +1104,58 @@ export type ClassProgressGroupsResponse = ApiEnvelope<{
|
|||||||
|
|
||||||
export type ClassProgressShowResponse = ApiEnvelope<{
|
export type ClassProgressShowResponse = ApiEnvelope<{
|
||||||
report?: ClassProgressReportRow
|
report?: ClassProgressReportRow
|
||||||
weekly_reports?: Record<string, ClassProgressReportRow>
|
weekly_reports?: ClassProgressReportRow[]
|
||||||
status_options?: Record<string, string>
|
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 = {
|
export type ParentRegistrationOverviewResponse = {
|
||||||
ok: true
|
ok: true
|
||||||
parent: ParentProfileRecord | null
|
parent: ParentProfileRecord | null
|
||||||
|
|||||||
@@ -22,6 +22,37 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
|
|||||||
'teacher/dashboard': '/app/teacher/dashboard',
|
'teacher/dashboard': '/app/teacher/dashboard',
|
||||||
'administrator/dashboard': '/app/administrator/dashboard',
|
'administrator/dashboard': '/app/administrator/dashboard',
|
||||||
'administrator/administratordashboard': '/app/admin/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',
|
'nav-builder': '/app/nav-builder',
|
||||||
'api/nav-builder': '/app/nav-builder',
|
'api/nav-builder': '/app/nav-builder',
|
||||||
'parent/parent_dashboard': '/app/parent/home',
|
'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 { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import {
|
||||||
import { fetchDashboardRoute } from '../api/session'
|
fetchAdministratorDashboardMetrics,
|
||||||
|
searchAdministratorDashboard,
|
||||||
|
} from '../api/session'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import type {
|
||||||
|
AdministratorDashboardMetricsResponse,
|
||||||
|
AdministratorDashboardSearchResponse,
|
||||||
|
} from '../api/types'
|
||||||
|
|
||||||
export function AppHome() {
|
export function AppHome() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [route, setRoute] = useState<string | null>(null)
|
const [metrics, setMetrics] = useState<AdministratorDashboardMetricsResponse | null>(null)
|
||||||
const [roles, setRoles] = useState<string[]>([])
|
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)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
setLoadingMetrics(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetchDashboardRoute()
|
const data = await fetchAdministratorDashboardMetrics()
|
||||||
const d = res.data?.dashboard
|
if (!cancelled) {
|
||||||
if (!cancelled && d) {
|
setMetrics(data)
|
||||||
setRoute(d.route)
|
|
||||||
setRoles(d.roles ?? [])
|
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(e instanceof Error ? e.message : 'Unable to load dashboard.')
|
setError(e instanceof Error ? e.message : 'Unable to load dashboard.')
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingMetrics(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
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 (
|
return (
|
||||||
<div>
|
<div className="container-fluid admin-dashboard">
|
||||||
<h2 className="h4 mb-3">Welcome</h2>
|
<section className="mb-4">
|
||||||
<p className="lead mb-4">
|
<h2>Welcome, {user?.name || 'Administrator'}</h2>
|
||||||
Signed in as <strong>{user?.name}</strong>.
|
<p>Here is an overview of your site's activity.</p>
|
||||||
</p>
|
</section>
|
||||||
|
|
||||||
<div className="row g-3 mb-4">
|
<section className="mb-5">
|
||||||
<div className="col-md-6">
|
<h2 className="mb-3">Search</h2>
|
||||||
<div className="card border-0 shadow-sm h-100">
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<div className="card-body">
|
|
||||||
<h3 className="h6 text-muted">Roles (from login)</h3>
|
<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">
|
<ul className="mb-0">
|
||||||
{roleKeys.length ? (
|
{(metrics?.recentActivities ?? []).map((item, index) => (
|
||||||
roleKeys.map((r) => <li key={r}>{r}</li>)
|
<li key={`${item.email ?? 'activity'}-${index}`}>
|
||||||
) : (
|
{item.email ?? 'Unknown user'} <span className="text-muted">· {item.login_time ?? '—'}</span>
|
||||||
<li className="text-muted">None reported</li>
|
</li>
|
||||||
)}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
</section>
|
||||||
<div className="card border-0 shadow-sm h-100">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="border rounded p-3 bg-white">
|
function SearchTable({
|
||||||
<h3 className="h6">Shortcuts</h3>
|
title,
|
||||||
<ul className="mb-0">
|
icon,
|
||||||
<li>
|
columns,
|
||||||
<Link to="/app/parent/home">Parent — Dashboard</Link> (parity with
|
rows,
|
||||||
CodeIgniter <code>parent/parent_dashboard</code>)
|
renderRow,
|
||||||
</li>
|
fullWidth = false,
|
||||||
<li>
|
}: {
|
||||||
<Link to="/app/parent/attendance">Parent — Attendance</Link> (parity
|
title: string
|
||||||
with CodeIgniter <code>Views/parent/attendance.php</code>)
|
icon: string
|
||||||
</li>
|
columns: string[]
|
||||||
<li>
|
rows: Record<string, unknown>[]
|
||||||
<Link to="/app/parent/home">Parent — All pages</Link> (navigation for
|
renderRow: (row: Record<string, unknown>) => string[]
|
||||||
every view under <code>Views/parent/</code>)
|
fullWidth?: boolean
|
||||||
</li>
|
}) {
|
||||||
<li>
|
return (
|
||||||
<Link to="/docs">Public API docs (Swagger)</Link>
|
<div className={`col-12 ${fullWidth ? '' : 'col-lg-6'}`}>
|
||||||
</li>
|
<div className="card">
|
||||||
<li>
|
<div className="card-header"><i className={`${icon} me-1`} aria-hidden />{title}</div>
|
||||||
<code>app/Views</code> screen as an <code>/app/…</code> parity route
|
<div className="card-body p-0">
|
||||||
</li>
|
<div className="table-responsive">
|
||||||
</ul>
|
<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>
|
||||||
</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 { user, roles, selectedRole, setSelectedRole } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [pickedRole, setPickedRole] = useState<string | null>(selectedRole)
|
const [busyRole, setBusyRole] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
|
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
|
||||||
|
|
||||||
async function continueLogin() {
|
async function continueLogin(nextRole: string) {
|
||||||
if (!pickedRole) {
|
|
||||||
setError('Please select a role to continue.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
setBusyRole(nextRole)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSelectedRole(pickedRole)
|
setSelectedRole(nextRole)
|
||||||
|
|
||||||
let target = from.startsWith('/app') ? from : '/app/home'
|
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'
|
target = '/app/parent/home'
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -39,41 +36,43 @@ export function RoleSelectPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-4">
|
<div className="container mt-5">
|
||||||
<div className="card border-0 shadow-sm mx-auto" style={{ maxWidth: 640 }}>
|
<div className="card shadow-sm border-0 mx-auto" style={{ maxWidth: 720 }}>
|
||||||
<div className="card-body p-4">
|
<div className="card-header bg-primary text-white">
|
||||||
<h2 className="h4 mb-2">Select role</h2>
|
<h4 className="mb-0">Select Account Type</h4>
|
||||||
<p className="text-muted mb-3">
|
</div>
|
||||||
{user?.name ? `Signed in as ${user.name}.` : 'Signed in successfully.'} Choose a role
|
<div className="card-body">
|
||||||
to continue.
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="list-group mb-3">
|
<div className="d-flex flex-wrap gap-3">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<button
|
<button
|
||||||
key={role}
|
key={role}
|
||||||
type="button"
|
type="button"
|
||||||
className={`list-group-item list-group-item-action text-start ${
|
className={`btn ${
|
||||||
pickedRole === role ? 'active' : ''
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="alert alert-danger py-2" role="alert">
|
<div className="alert alert-danger py-2 mt-3" role="alert">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="d-grid">
|
|
||||||
<button type="button" className="btn btn-success" onClick={continueLogin} disabled={busy}>
|
|
||||||
{busy ? 'Continuing…' : 'Continue'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user