add parent pages

This commit is contained in:
root
2026-04-23 02:24:05 -04:00
parent 9191fd32f0
commit 94700d4f0f
30 changed files with 8179 additions and 634 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-store" />
<title>Al Rahma Sunday School</title>
+3596
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+309 -162
View File
@@ -1,176 +1,323 @@
import { Suspense, lazy } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider'
import { RequireAuth } from './auth/RequireAuth'
import { PublicLayout } from './layout/PublicLayout'
import { ManagementLayout } from './layout/ManagementLayout'
import { AdminDocs } from './pages/AdminDocs'
import { AppHome } from './pages/AppHome'
import { CiPlaceholderPage } from './pages/CiPlaceholderPage'
import { CiSitemapPage } from './pages/CiSitemapPage'
import { ForgotPasswordPage } from './pages/ForgotPasswordPage'
import { HomePage } from './pages/HomePage'
import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
import { ParentAttendancePage } from './pages/parent/ParentAttendancePage'
import { ParentHomePage } from './pages/parent/ParentHomePage'
import {
ParentEmergencyContactEditPage,
ParentProgressAttachmentFilePage,
ParentProgressAttachmentPage,
ParentProgressViewPage,
ParentReportCardsSignPage,
ParentReportCardsViewPage,
ParentStudentEditPage,
} from './pages/parent/dynamicParityPages'
import {
ParentAddEmergencyFormPage,
ParentAddStudentFormPage,
ParentEditAllStudentsPage,
ParentUpdateProfileParityPage,
} from './pages/parent/extraParentParityPages'
import {
ParentAttendanceReportsHistoryPage,
ParentAuthorizedUsersPage,
} from './pages/parent/ParentAccountListPages'
import { ParentEmergencyContactsPage } from './pages/parent/ParentEmergencyContactsPage'
import {
RedirectParentLegacyEmergencyContactEdit,
RedirectParentLegacyStudentEdit,
} from './pages/parent/ParentCiRouteRedirects'
import {
ParentAddSecondParentPage,
ParentAssignmentsPage,
ParentCalendarPage,
ParentClassesPage,
ParentEnrollFailurePage,
ParentEnrollSuccessPage,
ParentInvoicePaymentPage,
ParentMessagePage,
ParentNoKidsRegisteredPage,
ParentPaymentSuccessPage,
ParentProgressListPage,
ParentReportCardsPage,
ParentScoresPage,
ParentSuccessMessagePage,
ParentWithdrawSuccessPage,
} from './pages/parent/staticParityPages'
import {
ParentContactPage,
ParentEnrollClassesPage,
ParentEventsPage,
ParentPaymentInvoicesPage,
ParentRegisterStudentPage,
ParentReportAttendancePage,
} from './pages/parent/WiredParentScreens'
import { SwaggerDocs } from './pages/SwaggerDocs'
import './App.css'
const PublicLayout = lazy(() =>
import('./layout/PublicLayout').then((m) => ({ default: m.PublicLayout })),
)
const ManagementLayout = lazy(() =>
import('./layout/ManagementLayout').then((m) => ({ default: m.ManagementLayout })),
)
const MainLayout = lazy(() =>
import('./layout/MainLayout').then((m) => ({ default: m.MainLayout })),
)
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
const RegisterPage = lazy(() =>
import('./pages/RegisterPage').then((m) => ({ default: m.RegisterPage })),
)
const ForgotPasswordPage = lazy(() =>
import('./pages/ForgotPasswordPage').then((m) => ({ default: m.ForgotPasswordPage })),
)
const AdminDocs = lazy(() => import('./pages/AdminDocs').then((m) => ({ default: m.AdminDocs })))
const SwaggerDocs = lazy(() =>
import('./pages/SwaggerDocs').then((m) => ({ default: m.SwaggerDocs })),
)
const RoleSelectPage = lazy(() =>
import('./pages/RoleSelectPage').then((m) => ({ default: m.RoleSelectPage })),
)
const AppHome = lazy(() => import('./pages/AppHome').then((m) => ({ default: m.AppHome })))
const CiSitemapPage = lazy(() =>
import('./pages/CiSitemapPage').then((m) => ({ default: m.CiSitemapPage })),
)
const CiPlaceholderPage = lazy(() =>
import('./pages/CiPlaceholderPage').then((m) => ({ default: m.CiPlaceholderPage })),
)
const NavBuilderPage = lazy(() =>
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
)
const ParentHomePage = lazy(() =>
import('./pages/parent/ParentHomePage').then((m) => ({ default: m.ParentHomePage })),
)
const ParentAttendancePage = lazy(() =>
import('./pages/parent/ParentAttendancePage').then((m) => ({ default: m.ParentAttendancePage })),
)
const ParentEmergencyContactsPage = lazy(() =>
import('./pages/parent/ParentEmergencyContactsPage').then((m) => ({
default: m.ParentEmergencyContactsPage,
})),
)
const ParentAttendanceReportsHistoryPage = lazy(() =>
import('./pages/parent/ParentAccountListPages').then((m) => ({
default: m.ParentAttendanceReportsHistoryPage,
})),
)
const ParentAuthorizedUsersPage = lazy(() =>
import('./pages/parent/ParentAccountListPages').then((m) => ({
default: m.ParentAuthorizedUsersPage,
})),
)
const RedirectParentLegacyStudentEdit = lazy(() =>
import('./pages/parent/ParentCiRouteRedirects').then((m) => ({
default: m.RedirectParentLegacyStudentEdit,
})),
)
const RedirectParentLegacyEmergencyContactEdit = lazy(() =>
import('./pages/parent/ParentCiRouteRedirects').then((m) => ({
default: m.RedirectParentLegacyEmergencyContactEdit,
})),
)
const ParentReportCardsSignPage = lazy(() =>
import('./pages/parent/dynamicParityPages').then((m) => ({
default: m.ParentReportCardsSignPage,
})),
)
const ParentReportCardsViewPage = lazy(() =>
import('./pages/parent/dynamicParityPages').then((m) => ({
default: m.ParentReportCardsViewPage,
})),
)
const ParentProgressViewPage = lazy(() =>
import('./pages/parent/dynamicParityPages').then((m) => ({
default: m.ParentProgressViewPage,
})),
)
const ParentProgressAttachmentPage = lazy(() =>
import('./pages/parent/dynamicParityPages').then((m) => ({
default: m.ParentProgressAttachmentPage,
})),
)
const ParentProgressAttachmentFilePage = lazy(() =>
import('./pages/parent/dynamicParityPages').then((m) => ({
default: m.ParentProgressAttachmentFilePage,
})),
)
const ParentEmergencyContactEditPage = lazy(() =>
import('./pages/parent/dynamicParityPages').then((m) => ({
default: m.ParentEmergencyContactEditPage,
})),
)
const ParentStudentEditPage = lazy(() =>
import('./pages/parent/dynamicParityPages').then((m) => ({ default: m.ParentStudentEditPage })),
)
const ParentEditAllStudentsPage = lazy(() =>
import('./pages/parent/extraParentParityPages').then((m) => ({
default: m.ParentEditAllStudentsPage,
})),
)
const ParentAddEmergencyFormPage = lazy(() =>
import('./pages/parent/extraParentParityPages').then((m) => ({
default: m.ParentAddEmergencyFormPage,
})),
)
const ParentAddStudentFormPage = lazy(() =>
import('./pages/parent/extraParentParityPages').then((m) => ({
default: m.ParentAddStudentFormPage,
})),
)
const ParentUpdateProfileParityPage = lazy(() =>
import('./pages/parent/extraParentParityPages').then((m) => ({
default: m.ParentUpdateProfileParityPage,
})),
)
const ParentAddSecondParentPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentAddSecondParentPage })),
)
const ParentAssignmentsPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentAssignmentsPage })),
)
const ParentCalendarPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentCalendarPage })),
)
const ParentClassesPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentClassesPage })),
)
const ParentEnrollFailurePage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentEnrollFailurePage })),
)
const ParentEnrollSuccessPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentEnrollSuccessPage })),
)
const ParentInvoicePaymentPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentInvoicePaymentPage })),
)
const ParentMessagePage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentMessagePage })),
)
const ParentNoKidsRegisteredPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({
default: m.ParentNoKidsRegisteredPage,
})),
)
const ParentPaymentSuccessPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentPaymentSuccessPage })),
)
const ParentProgressListPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentProgressListPage })),
)
const ParentReportCardsPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentReportCardsPage })),
)
const ParentScoresPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentScoresPage })),
)
const ParentSuccessMessagePage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentSuccessMessagePage })),
)
const ParentWithdrawSuccessPage = lazy(() =>
import('./pages/parent/staticParityPages').then((m) => ({ default: m.ParentWithdrawSuccessPage })),
)
const ParentContactPage = lazy(() =>
import('./pages/parent/WiredParentScreens').then((m) => ({ default: m.ParentContactPage })),
)
const ParentEnrollClassesPage = lazy(() =>
import('./pages/parent/WiredParentScreens').then((m) => ({
default: m.ParentEnrollClassesPage,
})),
)
const ParentEventsPage = lazy(() =>
import('./pages/parent/WiredParentScreens').then((m) => ({ default: m.ParentEventsPage })),
)
const ParentPaymentInvoicesPage = lazy(() =>
import('./pages/parent/WiredParentScreens').then((m) => ({
default: m.ParentPaymentInvoicesPage,
})),
)
const ParentRegisterStudentPage = lazy(() =>
import('./pages/parent/WiredParentScreens').then((m) => ({
default: m.ParentRegisterStudentPage,
})),
)
const ParentReportAttendancePage = lazy(() =>
import('./pages/parent/WiredParentScreens').then((m) => ({
default: m.ParentReportAttendancePage,
})),
)
function RouteFallback() {
return <div className="container py-4">Loading</div>
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route element={<PublicLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
</Route>
<Route
path="/docs"
element={
<div className="docs-root-inner py-3">
<SwaggerDocs bootstrapPath="/api/docs/public" />
</div>
}
/>
<Route path="/admin" element={<AdminDocs />} />
<Route element={<RequireAuth />}>
<Route path="/app" element={<ManagementLayout />}>
<Route index element={<Navigate to="home" replace />} />
<Route path="home" element={<AppHome />} />
<Route path="parent/add_second_parent" element={<Navigate to="/app/parent/add-second-parent" replace />} />
<Route path="parent/register_student" element={<Navigate to="/app/parent/register-student" replace />} />
<Route path="parent/enroll_classes" element={<Navigate to="/app/parent/enroll-classes" replace />} />
<Route path="parent/enroll_success" element={<Navigate to="/app/parent/enroll/success" replace />} />
<Route path="parent/enroll_failure" element={<Navigate to="/app/parent/enroll/failure" replace />} />
<Route path="parent/invoice_payment" element={<Navigate to="/app/parent/invoice-payment" replace />} />
<Route path="parent/viewSecondParent" element={<Navigate to="/app/parent/add-second-parent" replace />} />
<Route path="parent/child_register" element={<Navigate to="/app/parent/register-student" replace />} />
<Route path="parent/edit_student/:studentId" element={<RedirectParentLegacyStudentEdit />} />
<Route path="parent/edit-student/:studentId" element={<RedirectParentLegacyStudentEdit />} />
<Route path="parent_dashboard" element={<Navigate to="/app/parent/home" replace />} />
<Route path="parent/report-cards/sign/:studentId" element={<ParentReportCardsSignPage />} />
<Route
path="parent/edit-emergency-contact/:contactId"
element={<RedirectParentLegacyEmergencyContactEdit />}
/>
<Route
path="parent/edit_emergency_contact/:contactId"
element={<RedirectParentLegacyEmergencyContactEdit />}
/>
<Route path="parent/enroll/failure" element={<ParentEnrollFailurePage />} />
<Route path="parent/enroll/success" element={<ParentEnrollSuccessPage />} />
<Route path="parent/payment/success" element={<ParentPaymentSuccessPage />} />
<Route path="parent/withdraw/success" element={<ParentWithdrawSuccessPage />} />
<Route path="parent/report-cards/view/:reportId" element={<ParentReportCardsViewPage />} />
<Route path="parent/progress/view/:progressId" element={<ParentProgressViewPage />} />
<Route path="parent/progress/attachment/:attachmentId" element={<ParentProgressAttachmentPage />} />
<Route path="parent/progress/attachment-file/:fileId" element={<ParentProgressAttachmentFilePage />} />
<Route path="parent/emergency-contacts" element={<ParentEmergencyContactsPage />} />
<Route path="parent/edit-emergency-contact" element={<ParentEmergencyContactsPage />} />
<Route
path="parent/emergency-contact/:contactId"
element={<ParentEmergencyContactEditPage />}
/>
<Route path="parent/students/:studentId/edit" element={<ParentStudentEditPage />} />
<Route path="parent/edit-all-students" element={<ParentEditAllStudentsPage />} />
<Route path="parent/add-emergency-form" element={<ParentAddEmergencyFormPage />} />
<Route path="parent/add-student-form" element={<ParentAddStudentFormPage />} />
<Route path="parent/contact" element={<ParentContactPage />} />
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
<Route path="parent/events" element={<ParentEventsPage />} />
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
<Route path="parent/authorized-users" element={<ParentAuthorizedUsersPage />} />
<Route path="parent/update-profile" element={<ParentUpdateProfileParityPage />} />
<Route path="parent/home" element={<ParentHomePage />} />
<Route path="parent/attendance" element={<ParentAttendancePage />} />
<Route path="parent/add-second-parent" element={<ParentAddSecondParentPage />} />
<Route path="parent/assignments" element={<ParentAssignmentsPage />} />
<Route path="parent/calendar" element={<ParentCalendarPage />} />
<Route path="parent/classes" element={<ParentClassesPage />} />
<Route path="parent/progress" element={<ParentProgressListPage />} />
<Route path="parent/invoice-payment" element={<ParentInvoicePaymentPage />} />
<Route path="parent/no-kids-registered" element={<ParentNoKidsRegisteredPage />} />
<Route path="parent/message" element={<ParentMessagePage />} />
<Route path="parent/report-cards" element={<ParentReportCardsPage />} />
<Route path="parent/scores" element={<ParentScoresPage />} />
<Route path="parent/success" element={<ParentSuccessMessagePage />} />
<Route path="browse" element={<CiSitemapPage />} />
<Route path="*" element={<CiPlaceholderPage />} />
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route element={<PublicLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Route
path="/docs"
element={
<div className="docs-root-inner py-3">
<SwaggerDocs bootstrapPath="/api/docs/public" />
</div>
}
/>
<Route path="/admin" element={<AdminDocs />} />
<Route element={<RequireAuth />}>
<Route path="/app">
<Route element={<ManagementLayout />}>
<Route index element={<Navigate to="home" replace />} />
<Route path="select-role" element={<RoleSelectPage />} />
<Route path="home" element={<AppHome />} />
<Route path="nav-builder" element={<NavBuilderPage />} />
</Route>
<Route element={<MainLayout />}>
<Route path="parent/add_second_parent" element={<Navigate to="/app/parent/add-second-parent" replace />} />
<Route path="parent/register_student" element={<Navigate to="/app/parent/register-student" replace />} />
<Route path="parent/enroll_classes" element={<Navigate to="/app/parent/enroll-classes" replace />} />
<Route path="parent/enroll_success" element={<Navigate to="/app/parent/enroll/success" replace />} />
<Route path="parent/enroll_failure" element={<Navigate to="/app/parent/enroll/failure" replace />} />
<Route path="parent/invoice_payment" element={<Navigate to="/app/parent/invoice-payment" replace />} />
<Route path="parent/viewSecondParent" element={<Navigate to="/app/parent/add-second-parent" replace />} />
<Route path="parent/child_register" element={<Navigate to="/app/parent/register-student" replace />} />
<Route path="parent/edit_student/:studentId" element={<RedirectParentLegacyStudentEdit />} />
<Route path="parent/edit-student/:studentId" element={<RedirectParentLegacyStudentEdit />} />
<Route path="parent_dashboard" element={<Navigate to="/app/parent/home" replace />} />
<Route path="parent/report-cards/sign/:studentId" element={<ParentReportCardsSignPage />} />
<Route
path="parent/edit-emergency-contact/:contactId"
element={<RedirectParentLegacyEmergencyContactEdit />}
/>
<Route
path="parent/edit_emergency_contact/:contactId"
element={<RedirectParentLegacyEmergencyContactEdit />}
/>
<Route path="parent/enroll/failure" element={<ParentEnrollFailurePage />} />
<Route path="parent/enroll/success" element={<ParentEnrollSuccessPage />} />
<Route path="parent/payment/success" element={<ParentPaymentSuccessPage />} />
<Route path="parent/withdraw/success" element={<ParentWithdrawSuccessPage />} />
<Route path="parent/report-cards/view/:reportId" element={<ParentReportCardsViewPage />} />
<Route path="parent/progress/view/:progressId" element={<ParentProgressViewPage />} />
<Route path="parent/progress/attachment/:attachmentId" element={<ParentProgressAttachmentPage />} />
<Route path="parent/progress/attachment-file/:fileId" element={<ParentProgressAttachmentFilePage />} />
<Route path="parent/emergency-contacts" element={<ParentEmergencyContactsPage />} />
<Route path="parent/edit-emergency-contact" element={<ParentEmergencyContactsPage />} />
<Route
path="parent/emergency-contact/:contactId"
element={<ParentEmergencyContactEditPage />}
/>
<Route path="parent/students/:studentId/edit" element={<ParentStudentEditPage />} />
<Route path="parent/edit-all-students" element={<ParentEditAllStudentsPage />} />
<Route path="parent/add-emergency-form" element={<ParentAddEmergencyFormPage />} />
<Route path="parent/add-student-form" element={<ParentAddStudentFormPage />} />
<Route path="parent/contact" element={<ParentContactPage />} />
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
<Route path="parent/events" element={<ParentEventsPage />} />
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
<Route path="parent/authorized-users" element={<ParentAuthorizedUsersPage />} />
<Route path="parent/update-profile" element={<ParentUpdateProfileParityPage />} />
<Route path="parent/home" element={<ParentHomePage />} />
<Route path="parent/attendance" element={<ParentAttendancePage />} />
<Route path="parent/add-second-parent" element={<ParentAddSecondParentPage />} />
<Route path="parent/assignments" element={<ParentAssignmentsPage />} />
<Route path="parent/calendar" element={<ParentCalendarPage />} />
<Route path="parent/classes" element={<ParentClassesPage />} />
<Route path="parent/progress" element={<ParentProgressListPage />} />
<Route path="parent/invoice-payment" element={<ParentInvoicePaymentPage />} />
<Route path="parent/no-kids-registered" element={<ParentNoKidsRegisteredPage />} />
<Route path="parent/message" element={<ParentMessagePage />} />
<Route path="parent/report-cards" element={<ParentReportCardsPage />} />
<Route path="parent/scores" element={<ParentScoresPage />} />
<Route path="parent/success" element={<ParentSuccessMessagePage />} />
</Route>
<Route element={<ManagementLayout />}>
<Route path="browse" element={<CiSitemapPage />} />
<Route path="*" element={<CiPlaceholderPage />} />
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
)
+186 -7
View File
@@ -1,21 +1,33 @@
import { apiFetch } from './http'
import { apiUrl } from '../lib/apiOrigin'
import { apiFetch, getStoredToken } from './http'
import type {
ApiEnvelope,
DashboardPayload,
LoginFailure,
LoginResponse,
LoginSuccess,
NavBuilderDataResponse,
NavBuilderDeleteResponse,
NavBuilderSaveResponse,
NavMenuResponse,
ParentAttendanceResponse,
ParentAttendanceReportFormResponse,
ParentEnrollmentsResponse,
ParentEventsResponse,
ParentEventsOverviewResponse,
ParentInvoicesResponse,
ParentProfileResponse,
ParentRegistrationResponse,
ParentRegistrationOverviewResponse,
ParentEmergencyContactsResponse,
ParentAttendanceReportsListResponse,
ParentAuthorizedUsersApiResponse,
ParentMessagesResponse,
SchoolCalendarEventsResponse,
StudentScoresResponse,
ReportCardAcknowledgementResponse,
ReportCardMetaResponse,
ClassProgressGroupsResponse,
ClassProgressShowResponse,
ParentProfileRecord,
ContactSubmitResponse,
} from './types'
@@ -49,6 +61,42 @@ export async function fetchNavMenu(): Promise<NavMenuResponse> {
return apiFetch<NavMenuResponse>('/api/v1/nav-builder/menu')
}
export async function fetchNavBuilderData(): Promise<NavBuilderDataResponse> {
return apiFetch<NavBuilderDataResponse>('/api/v1/nav-builder/data')
}
export async function saveNavBuilderItem(payload: {
id?: number | null
menu_parent_id?: number | null
label: string
url?: string | null
icon_class?: string | null
target?: string | null
sort_order?: number
is_enabled?: boolean
roles?: number[]
}): Promise<NavBuilderSaveResponse> {
return apiFetch<NavBuilderSaveResponse>('/api/v1/nav-builder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function deleteNavBuilderItem(id: number): Promise<NavBuilderDeleteResponse> {
return apiFetch<NavBuilderDeleteResponse>(`/api/v1/nav-builder/${id}`, {
method: 'DELETE',
})
}
export async function reorderNavBuilderItems(orders: Record<string, number>): Promise<ApiEnvelope<{ ok: true }>> {
return apiFetch<ApiEnvelope<{ ok: true }>>('/api/v1/nav-builder/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orders }),
})
}
export async function fetchParentAttendance(
schoolYear: string | null,
): Promise<ParentAttendanceResponse> {
@@ -74,12 +122,12 @@ export async function fetchParentInvoices(
return apiFetch<ParentInvoicesResponse>(`/api/v1/parents/invoices${q}`)
}
export async function fetchParentEventsOverview(): Promise<ParentEventsResponse> {
return apiFetch<ParentEventsResponse>('/api/v1/parents/events')
export async function fetchParentEventsOverview(): Promise<ParentEventsOverviewResponse> {
return apiFetch<ParentEventsOverviewResponse>('/api/v1/parents/events')
}
export async function fetchParentRegistrationOverview(): Promise<ParentRegistrationResponse> {
return apiFetch<ParentRegistrationResponse>('/api/v1/parents/registration')
export async function fetchParentRegistrationOverview(): Promise<ParentRegistrationOverviewResponse> {
return apiFetch<ParentRegistrationOverviewResponse>('/api/v1/parents/registration')
}
export async function fetchAttendanceReportForm(): Promise<ParentAttendanceReportFormResponse> {
@@ -120,3 +168,134 @@ export async function fetchParentAttendanceReportsList(
export async function fetchParentAuthorizedUsers(): Promise<ParentAuthorizedUsersApiResponse> {
return apiFetch<ParentAuthorizedUsersApiResponse>('/api/v1/parents/authorized-users')
}
export async function inviteParentAuthorizedUser(email: string): Promise<ContactSubmitResponse> {
return apiFetch<ContactSubmitResponse>('/api/v1/parents/authorized-users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
}
export async function fetchParentMessagesInbox(): Promise<ParentMessagesResponse> {
return apiFetch<ParentMessagesResponse>('/api/v1/messages/inbox')
}
export async function fetchSchoolCalendarEvents(): Promise<SchoolCalendarEventsResponse> {
return apiFetch<SchoolCalendarEventsResponse>(
'/api/v1/settings/school-calendar/events?audience=parent',
)
}
export async function fetchStudentScores(studentId: number): Promise<StudentScoresResponse> {
return apiFetch<StudentScoresResponse>(`/api/v1/students/${studentId}/scores`)
}
export async function fetchReportCardMeta(): Promise<ReportCardMetaResponse> {
return apiFetch<ReportCardMetaResponse>('/api/v1/reports/report-cards/meta')
}
export async function fetchReportCardAcknowledgement(
studentId: number,
schoolYear?: string | null,
semester?: string | null,
): Promise<ReportCardAcknowledgementResponse> {
const query = new URLSearchParams({ student_id: String(studentId) })
if (schoolYear) query.set('school_year', schoolYear)
if (semester) query.set('semester', semester)
return apiFetch<ReportCardAcknowledgementResponse>(
`/api/v1/reports/report-cards/acknowledgement?${query.toString()}`,
)
}
export async function fetchClassProgressGroups(): Promise<ClassProgressGroupsResponse> {
return apiFetch<ClassProgressGroupsResponse>('/api/v1/class-progress?group_by_week=1')
}
export async function fetchClassProgressDetail(id: number): Promise<ClassProgressShowResponse> {
return apiFetch<ClassProgressShowResponse>(`/api/v1/class-progress/${id}`)
}
export async function updateParentProfile(
payload: Required<
Pick<
ParentProfileRecord,
'firstname' | 'lastname' | 'cellphone' | 'address_street' | 'city' | 'state' | 'zip'
>
>,
): Promise<ParentProfileResponse> {
return apiFetch<ParentProfileResponse>('/api/v1/parents/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function updateParentStudent(
studentId: number,
payload: Record<string, unknown>,
): Promise<{ ok: true }> {
return apiFetch<{ ok: true }>(`/api/v1/parents/students/${studentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function storeParentRegistration(payload: {
students?: Record<string, unknown>[]
emergency_contacts?: Record<string, unknown>[]
}): Promise<{ ok: true; created_student_ids?: number[] }> {
return apiFetch<{ ok: true; created_student_ids?: number[] }>('/api/v1/parents/registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function storeParentEmergencyContact(payload: {
name: string
cellphone: string
email?: string
relation?: string
}): Promise<ParentEmergencyContactsResponse> {
return apiFetch<ParentEmergencyContactsResponse>('/api/v1/parents/emergency-contacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function updateParentEmergencyContact(
contactId: number,
payload: { name: string; cellphone: string; email?: string; relation?: string },
): Promise<ParentEmergencyContactsResponse> {
return apiFetch<ParentEmergencyContactsResponse>(`/api/v1/parents/emergency-contacts/${contactId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function submitParentAttendanceReport(payload: Record<string, unknown>): Promise<{
ok: true
inserted?: number
blocked?: number
}> {
return apiFetch('/api/v1/parents/attendance-reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function fetchReportCardPdf(studentId: number): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiUrl(`/api/v1/reports/report-cards/students/${studentId}`), {
headers,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.blob()
}
+213 -2
View File
@@ -46,6 +46,47 @@ export type NavItem = {
export type NavMenuResponse = ApiEnvelope<{ items: NavItem[] }>
export type NavBuilderRoleOption = {
id: number
name: string
}
export type NavBuilderParentOption = {
id: number
label: string
}
export type NavBuilderItem = {
id: number
label: string
url?: string | null
icon_class?: string | null
target?: string | null
menu_parent_id?: number | null
parent_id?: number | null
parent_label?: string | null
sort_order?: number
order?: number
is_enabled?: number | boolean
enabled?: boolean
depth?: number
roles?: {
ids?: number[]
names?: string[]
}
raw?: Record<string, unknown>
}
export type NavBuilderData = {
items: NavBuilderItem[]
roles: NavBuilderRoleOption[]
parentOptions: NavBuilderParentOption[]
}
export type NavBuilderDataResponse = ApiEnvelope<{ data: NavBuilderData }>
export type NavBuilderSaveResponse = ApiEnvelope<{ id: number }>
export type NavBuilderDeleteResponse = ApiEnvelope<null>
export type ParentAttendanceRow = {
firstname: string
lastname: string
@@ -96,7 +137,13 @@ export type ParentEnrollmentStudent = {
id: number
firstname: string
lastname: string
dob?: string | null
gender?: string | null
registration_grade?: string | null
class_section?: string | null
enrollment_status?: string | null
admission_status?: string | null
disable_enroll?: boolean
}
export type ParentEnrollmentsResponse = {
@@ -114,7 +161,15 @@ export type ContactSubmitResponse = {
/** Loose JSON payloads for complex parent dashboards (events, registration, attendance report form). */
export type ParentEventsResponse = Record<string, unknown>
export type ParentRegistrationResponse = Record<string, unknown>
export type ParentAttendanceReportFormResponse = Record<string, unknown>
export type ParentAttendanceReportFormResponse = {
ok: true
students?: ParentEnrollmentStudent[]
today?: string
sundays?: string[]
defaultDate?: string
myReports?: Record<string, unknown>[]
cutoffThreshold?: string | null
}
export type ParentEmergencyContactRow = {
id: number
@@ -135,4 +190,160 @@ export type ParentAttendanceReportsListResponse = {
}
/** `GET /api/v1/parents/authorized-users` — envelope from BaseApiController. */
export type ParentAuthorizedUsersApiResponse = ApiEnvelope<unknown[]>
export type ParentAuthorizedUserRow = {
id?: number
user_id?: number
email?: string | null
created_at?: string | null
updated_at?: string | null
[key: string]: unknown
}
export type ParentAuthorizedUsersApiResponse = ApiEnvelope<ParentAuthorizedUserRow[]>
export type ParentMessageRow = {
id?: number | null
message_number?: string | number | null
sender_name?: string | null
recipient_name?: string | null
subject?: string | null
message?: string | null
sent_datetime?: string | null
read_status?: boolean
priority?: string | null
}
export type ParentMessagesResponse = ApiEnvelope<{
messages: ParentMessageRow[]
meta?: Record<string, unknown> | null
}>
export type SchoolCalendarEventRow = {
id?: number | null
title: string
start: string
description?: string
extendedProps?: {
event_type?: string
backgroundColor?: string
color?: string
[key: string]: unknown
}
}
export type SchoolCalendarEventsResponse = ApiEnvelope<{
events: SchoolCalendarEventRow[]
}>
export type StudentScoreRow = {
id?: number
student_id?: number
school_year?: string | null
semester?: string | null
class_section_id?: number | null
homework_score?: number | string | null
project_score?: number | string | null
participation_score?: number | string | null
quiz_score?: number | string | null
attendance_score?: number | string | null
ptap_score?: number | string | null
midterm_exam_score?: number | string | null
final_exam_score?: number | string | null
semester_score?: number | string | null
[key: string]: unknown
}
export type StudentScoresResponse = {
ok: true
student_id: number
rows: StudentScoreRow[]
}
export type ReportCardStudentMetaRow = {
id: number
firstname: string
lastname: string
class_section_name: string
}
export type ReportCardMetaResponse = ApiEnvelope<{
schoolYears: string[]
selectedYear: string | null
semesters: string[]
selectedSemester: string | null
students: ReportCardStudentMetaRow[]
}>
export type ReportCardAcknowledgementRow = {
student_id: number
school_year: string
semester: string
viewed_at?: string | null
signed_at?: string | null
signed_name?: string | null
}
export type ReportCardAcknowledgementResponse = ApiEnvelope<ReportCardAcknowledgementRow>
export type ClassProgressReportRow = {
id: number
teacher_name?: string | null
class_section_name?: string | null
subject?: string | null
unit_title?: string | null
status?: string | null
status_label?: string | null
covered?: string | null
homework?: string | null
support_needed?: string | null
week_start?: string | null
week_end?: string | null
attachments?: { id?: number; original_name?: string | null; url?: string | null }[]
}
export type ClassProgressGroupRow = {
week_start?: string | null
week_end?: string | null
class_section_id?: number | null
class_section_name?: string | null
reports?: Record<string, ClassProgressReportRow>
}
export type ClassProgressGroupsResponse = ApiEnvelope<{
items: ClassProgressGroupRow[]
pagination?: Record<string, unknown>
}>
export type ClassProgressShowResponse = ApiEnvelope<{
report?: ClassProgressReportRow
weekly_reports?: Record<string, ClassProgressReportRow>
status_options?: Record<string, string>
}>
export type ParentRegistrationOverviewResponse = {
ok: true
parent: ParentProfileRecord | null
existingKids: ParentEnrollmentStudent[]
emergencies: ParentEmergencyContactRow[]
maxChilds?: number
maxEmergency?: number
lastDayOfRegistration?: string | null
registrationAgeDeadline?: string | null
}
export type ParentEventRow = {
id?: number
title?: string | null
event_name?: string | null
event_date?: string | null
start_date?: string | null
description?: string | null
[key: string]: unknown
}
export type ParentEventsOverviewResponse = {
ok: true
activeEvents?: ParentEventRow[]
charges?: Record<string, unknown>[]
yourStudents?: ParentEnrollmentStudent[]
}
+63 -7
View File
@@ -14,9 +14,16 @@ export type { AuthUser }
type AuthState = {
token: string | null
user: AuthUser | null
selectedRole: string | null
}
const STORAGE_USER = 'alrahma_api_user'
const STORAGE_ROLE = 'alrahma_selected_role'
function enabledRoles(user: AuthUser | null): string[] {
if (!user?.roles) return []
return Object.keys(user.roles).filter((k) => user.roles[k])
}
function readUser(): AuthUser | null {
try {
@@ -33,11 +40,30 @@ function writeUser(user: AuthUser | null): void {
else localStorage.removeItem(STORAGE_USER)
}
function readSelectedRole(): string | null {
const role = localStorage.getItem(STORAGE_ROLE)
return role && role.trim() ? role : null
}
function writeSelectedRole(role: string | null): void {
if (role) localStorage.setItem(STORAGE_ROLE, role)
else localStorage.removeItem(STORAGE_ROLE)
}
function validSelectedRole(user: AuthUser | null, role: string | null): string | null {
if (!role) return null
const roles = enabledRoles(user)
return roles.includes(role) ? role : null
}
let memory: AuthState = {
token: getStoredToken(),
user: readUser(),
selectedRole: null,
}
memory.selectedRole = validSelectedRole(memory.user, readSelectedRole())
const listeners = new Set<() => void>()
function emit(): void {
@@ -59,9 +85,11 @@ function getSnapshot(): AuthState {
}
export function hydrateAuthFromStorage(): void {
const user = readUser()
memory = {
token: getStoredToken(),
user: readUser(),
user,
selectedRole: validSelectedRole(user, readSelectedRole()),
}
emit()
}
@@ -77,22 +105,42 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
message: result.message ?? 'Login failed.',
}
}
const roles = enabledRoles(result.user)
const nextRole = roles.length === 1 ? roles[0] : null
setStoredToken(result.token)
writeUser(result.user)
setAuth({ token: result.token, user: result.user })
return { ok: true as const }
writeSelectedRole(nextRole)
setAuth({ token: result.token, user: result.user, selectedRole: nextRole })
return {
ok: true as const,
requiresRoleSelection: roles.length > 1,
roles,
selectedRole: nextRole,
}
}, [])
const logout = useCallback(() => {
setStoredToken(null)
writeUser(null)
setAuth({ token: null, user: null })
writeSelectedRole(null)
setAuth({ token: null, user: null, selectedRole: null })
}, [])
const setSession = useCallback((token: string, user: AuthUser) => {
const roles = enabledRoles(user)
const nextRole = roles.length === 1 ? roles[0] : null
setStoredToken(token)
writeUser(user)
setAuth({ token, user })
writeSelectedRole(nextRole)
setAuth({ token, user, selectedRole: nextRole })
}, [])
const setSelectedRole = useCallback((role: string | null) => {
const roles = enabledRoles(memory.user)
const nextRole = role && roles.includes(role) ? role : null
writeSelectedRole(nextRole)
setAuth({ ...memory, selectedRole: nextRole })
}, [])
const value = useMemo(
@@ -100,11 +148,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login,
logout,
setSession,
setSelectedRole,
token: state.token,
user: state.user,
selectedRole: state.selectedRole,
roles: enabledRoles(state.user),
isAuthenticated: Boolean(state.token && state.user),
}),
[state.token, state.user, login, logout, setSession],
[state.token, state.user, state.selectedRole, login, logout, setSession, setSelectedRole],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
@@ -114,11 +165,16 @@ type AuthContextValue = {
login: (
email: string,
password: string,
) => Promise<{ ok: true } | { ok: false; message: string }>
) => Promise<
{ ok: true; requiresRoleSelection: boolean; roles: string[]; selectedRole: string | null } | { ok: false; message: string }
>
logout: () => void
setSession: (token: string, user: AuthUser) => void
setSelectedRole: (role: string | null) => void
token: string | null
user: AuthUser | null
selectedRole: string | null
roles: string[]
isAuthenticated: boolean
}
+6 -1
View File
@@ -2,12 +2,17 @@ import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from './AuthProvider'
export function RequireAuth() {
const { isAuthenticated } = useAuth()
const { isAuthenticated, roles, selectedRole } = useAuth()
const location = useLocation()
if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />
}
const needsRoleSelection = roles.length > 1 && !selectedRole
if (needsRoleSelection && location.pathname !== '/app/select-role') {
return <Navigate to="/app/select-role" replace state={{ from: location.pathname }} />
}
return <Outlet />
}
+965
View File
@@ -0,0 +1,965 @@
import { Link } from 'react-router-dom'
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
export type CiFlashMessage = {
id?: string
type: 'success' | 'error' | 'info' | 'warning'
message: ReactNode
}
const alertType = {
success: 'success',
error: 'danger',
info: 'info',
warning: 'warning',
} as const
export function CiFlashMessages({
messages,
autoHideMs = 4000,
}: {
messages: CiFlashMessage[]
autoHideMs?: number
}) {
const [visible, setVisible] = useState(messages)
useEffect(() => {
setVisible(messages)
}, [messages])
useEffect(() => {
if (autoHideMs <= 0 || visible.length === 0) return
const timeout = window.setTimeout(() => setVisible([]), autoHideMs)
return () => window.clearTimeout(timeout)
}, [autoHideMs, visible.length])
return (
<>
{visible.map((item, index) => (
<div
key={item.id ?? `${item.type}-${index}`}
className={`alert alert-${alertType[item.type]} alert-dismissible fade show`}
role="alert"
>
{item.message}
<button
type="button"
className="btn-close"
aria-label="Close"
onClick={() => setVisible((current) => current.filter((_, i) => i !== index))}
/>
</div>
))}
</>
)
}
export function CiAcademicFilter({
schoolYears,
selectedYear,
selectedSemester,
classSectionId,
onApply,
}: {
schoolYears?: Array<string | { school_year: string }>
selectedYear?: string | null
selectedSemester?: string | null
classSectionId?: string | number | null
onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void
}) {
const years = (schoolYears ?? []).map((year) => (typeof year === 'string' ? year : year.school_year))
const [schoolYear, setSchoolYear] = useState(selectedYear ?? years[0] ?? '')
const [semester, setSemester] = useState(selectedSemester ?? '')
useEffect(() => {
setSchoolYear(selectedYear ?? years[0] ?? '')
}, [selectedYear, years.join('|')])
useEffect(() => {
setSemester(selectedSemester ?? '')
}, [selectedSemester])
return (
<form
className="row g-2 align-items-center justify-content-center mb-3 ci-academic-filter"
onSubmit={(event) => {
event.preventDefault()
onApply?.({
schoolYear,
semester,
classSectionId: classSectionId == null ? undefined : String(classSectionId),
})
}}
>
<div className="col-auto">
<label className="form-label mb-0" htmlFor="ci_school_year">
School year
</label>
</div>
<div className="col-auto">
<select
id="ci_school_year"
name="school_year"
className="form-select form-select-sm ci-academic-filter__year"
value={schoolYear}
onChange={(event) => setSchoolYear(event.target.value)}
>
{years.length > 0 ? (
years.map((year) => (
<option key={year} value={year}>
{year}
</option>
))
) : (
<option value={schoolYear}>{schoolYear || '-'}</option>
)}
</select>
</div>
<div className="col-auto">
<label className="form-label mb-0" htmlFor="ci_semester">
Semester
</label>
</div>
<div className="col-auto">
<select
id="ci_semester"
name="semester"
className="form-select form-select-sm ci-academic-filter__semester"
value={semester}
onChange={(event) => setSemester(event.target.value)}
>
<option value="">-</option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="col-auto">
<button type="submit" className="btn btn-secondary btn-sm">
Apply
</button>{' '}
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => {
setSchoolYear(years[0] ?? '')
setSemester('')
onApply?.({ schoolYear: years[0] ?? '', semester: '', classSectionId: undefined })
}}
>
Reset
</button>
</div>
</form>
)
}
export function CiStandardStudentTable({
rows,
customHeaders = [],
customKeys = [],
emptyText = 'No rows found.',
}: {
rows: Record<string, unknown>[]
customHeaders?: string[]
customKeys?: string[]
emptyText?: string
}) {
return (
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead>
<tr>
<th>#</th>
<th>School ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Gender</th>
<th>Age</th>
<th>Grade</th>
{customHeaders.map((header) => (
<th key={header}>{header}</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={7 + customHeaders.length} className="text-muted">
{emptyText}
</td>
</tr>
) : (
rows.map((row, index) => (
<tr key={String(row.id ?? row.school_id ?? index)}>
<td>{index + 1}</td>
<td>{display(row.school_id)}</td>
<td>{display(row.first_name ?? row.firstname)}</td>
<td>{display(row.last_name ?? row.lastname)}</td>
<td>{display(row.gender)}</td>
<td>{display(row.age)}</td>
<td>{display(row.Grade ?? row.grade ?? row.registration_grade)}</td>
{customKeys.map((key) => (
<td key={key}>{display(row[key])}</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
const publicGradeOptions = [
['NA', 'Not enrolled in public school'],
['KG', 'Pre-K / Kindergarten'],
['1', 'Grade 1'],
['2', 'Grade 2'],
['3', 'Grade 3'],
['4', 'Grade 4'],
['5', 'Grade 5'],
['6', 'Grade 6'],
['7', 'Grade 7'],
['8', 'Grade 8'],
['9', 'Grade 9'],
['10', 'Grade 10'],
['11', 'Grade 11'],
['12', 'Grade 12'],
]
const alRahmaGradeOptions = [
['KG', 'Pre-K / Kindergarten'],
['1', 'Grade 1'],
['2', 'Grade 2'],
['3', 'Grade 3'],
['4', 'Grade 4'],
['5', 'Grade 5'],
['6', 'Grade 6'],
['7', 'Grade 7'],
['8', 'Grade 8'],
['9', 'Grade 9'],
['10', 'Youth'],
]
export const medicalConditionOptions = [
'None',
'ADHD (Attention-Deficit/Hyperactivity Disorder)',
'Anxiety or Emotional Disorders',
'Asthma',
'Autism Spectrum Disorder (ASD)',
'Behavioral or Conduct Disorders',
'Blindness / Vision Impairment',
'Celiac Disease (Gluten Intolerance)',
'Cerebral Palsy',
'Cystic Fibrosis',
'Depression',
'Diabetes (Type 1 or Type 2)',
'Down Syndrome',
'Dyslexia or Learning Disabilities',
'Eating Disorders',
'Eczema / Severe Skin Conditions',
'Epilepsy / Seizure Disorders',
'Hearing Impairments / Deafness',
'Heart Conditions (congenital or acquired)',
'Hemophilia / Bleeding Disorders',
'Kidney Disease',
'Migraines / Chronic Headaches',
'Obsessive-Compulsive Disorder (OCD)',
'Physical Disabilities / Mobility Impairments',
'PTSD (Post-Traumatic Stress Disorder)',
'Sickle Cell Anemia',
'Speech and Language Disorders',
'Thyroid Disorders',
'Tourette Syndrome',
'Traumatic Brain Injury (TBI)',
'Rheumatic diseases',
"Ulcerative Colitis / Crohn's Disease",
'Other',
]
export const allergyOptions = [
'None',
'Animal Dander (cats, dogs, etc.)',
'Antibiotics',
'Bee stings',
'Cockroach',
'Corn',
'Dust Mites',
'Egg',
'Fire ant stings',
'Fish',
'Fragrances / Perfumes',
'Latex',
'Milk / Dairy',
'Mold',
'Mosquito bites',
'Peanut',
'Pollen (grass, tree, weed)',
'Sesame',
'Shellfish (shrimp, crab, lobster, etc.)',
'Soy',
'Tree Nuts (almond, cashew, walnut, etc.)',
'Wasp stings',
'Wheat / Gluten',
'Other',
]
export function CiStudentFormPartial({
defaultValues,
showRemoveButton = false,
}: {
defaultValues?: {
firstname?: string | null
lastname?: string | null
dob?: string | null
gender?: string | null
registration_grade?: string | null
photo_consent?: boolean | string | null
last_year?: 'yes' | 'no' | string | null
allergies?: string[]
medical_conditions?: string[]
}
showRemoveButton?: boolean
}) {
const [lastYear, setLastYear] = useState(defaultValues?.last_year ?? (defaultValues?.registration_grade ? 'yes' : ''))
const [selectedGrade, setSelectedGrade] = useState(defaultValues?.registration_grade ?? '')
const gradeOptions = lastYear === 'yes' ? alRahmaGradeOptions : lastYear === 'no' ? publicGradeOptions : []
const gradeLabel =
lastYear === 'yes'
? 'Al Rahma School Last Year Grade'
: lastYear === 'no'
? 'Public School Last Year Grade'
: 'Last Year Grade'
const defaultMedical = useMemo(() => new Set(defaultValues?.medical_conditions ?? []), [defaultValues?.medical_conditions])
const defaultAllergies = useMemo(() => new Set(defaultValues?.allergies ?? []), [defaultValues?.allergies])
return (
<div className="student-form border rounded p-3 mb-4 bg-light position-relative">
{showRemoveButton ? (
<button type="button" className="btn-close position-absolute end-0 top-0 m-2" aria-label="Close" />
) : null}
<h5 className="mb-3 text-primary">Student Information</h5>
<div className="col-md-12 mb-3">
<label className="form-label fw-bold mb-2 d-block">
Was your child enrolled in Al Rahma Sunday School last year? <span className="text-danger">*</span>
</label>
<div className="d-flex align-items-center flex-wrap gap-3">
{['yes', 'no'].map((value) => (
<div className="form-check ps-3" key={value}>
<input
className="form-check-input"
type="radio"
name="last_year"
value={value}
checked={lastYear === value}
onChange={(event) => {
setLastYear(event.target.value)
setSelectedGrade('')
}}
required
/>
<label className="form-check-label">{value === 'yes' ? 'Yes' : 'No'}</label>
</div>
))}
</div>
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">
First Name <span className="text-danger">*</span>
</label>
<input name="firstname" className="form-control" maxLength={30} defaultValue={defaultValues?.firstname ?? ''} required />
</div>
<div className="col-md-6 mb-3">
<label className="form-label">
Last Name <span className="text-danger">*</span>
</label>
<input name="lastname" className="form-control" maxLength={30} defaultValue={defaultValues?.lastname ?? ''} required />
</div>
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">
Date of Birth <span className="text-danger">*</span>
</label>
<input type="date" className="form-control" name="dob" defaultValue={defaultValues?.dob ?? ''} required />
</div>
<div className="col-md-6 mb-3">
<label className="form-label">
{gradeLabel} <span className="text-danger">*</span>
</label>
<select
className="form-select"
name="registration_grade"
value={selectedGrade}
onChange={(event) => setSelectedGrade(event.target.value)}
disabled={gradeOptions.length === 0}
required
>
<option value="">Select Grade</option>
{gradeOptions.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">
Gender <span className="text-danger">*</span>
</label>
<select name="gender" className="form-select" defaultValue={defaultValues?.gender ?? ''} required>
<option value="">Select Gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div className="col-md-6 mb-3">
<label className="form-label">
Photo Consent <span className="text-danger">*</span>
</label>
<select
name="photo_consent"
className="form-select"
defaultValue={defaultValues?.photo_consent === true || defaultValues?.photo_consent === 'Yes' ? 'Yes' : ''}
required
>
<option value="">Select Answer</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
</div>
<div className="row">
<Checklist title="Medical Conditions" name="medical_conditions" options={medicalConditionOptions} defaultSelected={defaultMedical} />
<Checklist title="Allergies" name="allergies" options={allergyOptions} defaultSelected={defaultAllergies} />
</div>
</div>
)
}
export function CiEmergencyContactFormPartial({
defaultValues,
}: {
defaultValues?: {
name?: string | null
cellphone?: string | null
email?: string | null
relation?: string | null
}
}) {
return (
<div className="emergency-contact-form border rounded p-3 mb-3 bg-white shadow-sm">
<h5 className="mb-3 text-primary">Emergency Contact Information</h5>
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">
Name <span className="text-danger">*</span>
</label>
<input type="text" className="form-control" name="name" required maxLength={60} defaultValue={defaultValues?.name ?? ''} />
</div>
<div className="col-md-6 mb-3">
<label className="form-label">
Phone Number <span className="text-danger">*</span>
</label>
<input
type="tel"
className="form-control"
name="cellphone"
required
maxLength={12}
defaultValue={defaultValues?.cellphone ?? ''}
/>
</div>
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label className="form-label">Email</label>
<input type="email" className="form-control" name="email" defaultValue={defaultValues?.email ?? ''} />
</div>
<div className="col-md-6 mb-3">
<label className="form-label">
Relationship to Student(s) <span className="text-danger">*</span>
</label>
<select className="form-select" name="relation" required defaultValue={defaultValues?.relation ?? ''}>
<option value="">Select Relationship</option>
<option value="Parent">Parent</option>
<option value="Sibling">Sibling</option>
<option value="Relative">Relative</option>
<option value="Friend">Friend</option>
<option value="Neighbor">Neighbor</option>
<option value="Guardian">Guardian</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
)
}
export function CiNoInvoiceFound({ message = 'No invoice found.' }: { message?: string }) {
return (
<div className="modal d-block ci-static-modal" tabIndex={-1} role="dialog" aria-modal="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Error</h5>
</div>
<div className="modal-body">
<p className="mb-0">{message}</p>
</div>
</div>
</div>
</div>
)
}
export function CiPublicFooter({ year = new Date().getFullYear() }: { year?: number }) {
return (
<footer className="footer mt-auto custom-footer text-white py-4">
<div className="container">
<div className="row align-items-start justify-content-between">
<div className="col-md-6 col-12 mb-3 mb-md-0 text-md-start">
<ul className="list-unstyled mb-0 info-list">
<li>
<i className="fa fa-map-marker-alt me-2" aria-hidden />
5 Courthouse Lane, Chelmsford, MA 01824
</li>
<li>
<i className="fa fa-phone-alt me-2" aria-hidden />
+1 978-364-0219
</li>
<li>
<i className="fa fa-envelope me-2" aria-hidden />
alrahma.isgl@gmail.com
</li>
</ul>
</div>
<div className="col-md-4 col-12 text-center text-md-end">
<ul className="list-unstyled mb-0 info-list">
<li><a href="/privacy_policy.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />Privacy Policy</a></li>
<li><a href="/terms_of_service.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />Terms of Service</a></li>
<li><a href="/account_creation_guide.pdf" target="_blank" rel="noopener noreferrer" className="pdf-link"><i className="fas fa-file-pdf me-1" aria-hidden />How To Create An Account</a></li>
</ul>
</div>
</div>
</div>
<p className="text-center text-white mb-0 mt-3">© {year} Al Rahma Sunday School by ISGL. All Rights Reserved.</p>
</footer>
)
}
export function CiBackFooter({ year = new Date().getFullYear() }: { year?: number }) {
return (
<footer className="footer bg-white text-muted border-top">
<div className="container py-3">
<p className="text-center mb-0">
© {year} Al Rahma Sunday School by{' '}
<a className="text-decoration-underline" href="https://isgl.org" target="_blank" rel="noopener noreferrer">ISGL</a>.
{' '}All Rights Reserved.
</p>
</div>
</footer>
)
}
export function CiAvatarMenu({
name,
email,
currentRole,
roles = [],
onSwitchRole,
onLogout,
}: {
name?: string | null
email?: string | null
currentRole?: string | null
roles?: string[]
onSwitchRole?: (role: string) => void
onLogout?: () => void
}) {
const [open, setOpen] = useState(false)
const [roleOpen, setRoleOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
const initials = userInitials(name, email)
const normalizedRole = currentRole || 'guest'
const otherRoles = roles.filter((role) => role && role !== normalizedRole)
return (
<div className="dropdown ci-avatar-menu">
<button
type="button"
className="btn p-0 bg-transparent border-0"
aria-label="Open user menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<div className="rounded-circle text-white fw-semibold d-flex align-items-center justify-content-center ci-avatar-menu__avatar">
{initials}
</div>
</button>
<div className={`dropdown-menu dropdown-menu-end quick-menu p-0 ${open ? 'show' : ''}`}>
<div className="px-3 py-3 d-flex align-items-center">
<div className="rounded-circle text-white fw-semibold d-flex align-items-center justify-content-center me-3 ci-avatar-menu__avatar-sm">
{initials}
</div>
<div>
<div className="fw-semibold mb-0 lh-sm">{name || 'User'}</div>
<div className="text-muted small lh-sm">
{normalizedRole}
{email ? ` · ${email}` : ''}
</div>
</div>
</div>
<div className="dropdown-divider" />
{otherRoles.length > 0 ? (
<>
<button
type="button"
className="dropdown-item d-flex justify-content-between align-items-center"
onClick={() => setRoleOpen((value) => !value)}
>
Switch Role <i className="bi bi-chevron-down ms-2" aria-hidden />
</button>
{roleOpen ? (
<div className="px-2 pb-2">
{otherRoles.map((role) => (
<button key={role} type="button" className="dropdown-item" onClick={() => onSwitchRole?.(role)}>
Switch to {humanRole(role)}
</button>
))}
</div>
) : null}
<div className="dropdown-divider" />
</>
) : null}
<button
type="button"
className="dropdown-item d-flex justify-content-between align-items-center"
onClick={() => setStyleOpen((value) => !value)}
>
Style <i className="bi bi-chevron-down ms-2" aria-hidden />
</button>
{styleOpen ? (
<div className="px-2 pb-2">
<div className="px-2 small text-muted">Menu</div>
<div className="d-grid gap-2 px-2 pb-2">
{['emerald', 'white', 'dark'].map((menu) => (
<Link key={menu} className="btn btn-outline-secondary btn-sm" to={`/app/preferences?menu=${menu}`}>
{humanRole(menu)}
</Link>
))}
</div>
</div>
) : null}
<div className="dropdown-divider" />
<button type="button" className="dropdown-item" onClick={onLogout}>Log Out</button>
</div>
</div>
)
}
export function CiParentSidebar() {
const links = [
['/app/parent/home', 'bi bi-house-door', 'Dashboard'],
['/app/parent/register-student', 'bi bi-person-plus', 'Register Kids'],
['/app/parent/enroll-classes', 'bi bi-pencil-square', 'Enroll in Classes'],
['/app/parent/attendance', 'bi bi-calendar-check', 'Attendance'],
['/app/parent/scores', 'bi bi-clipboard-data', 'Scores'],
['/app/parent/payment', 'bi bi-credit-card', 'Payment'],
['/app/parent/message', 'bi bi-chat-dots', 'Messages'],
['/app/parent/calendar', 'bi bi-calendar', 'Calendar'],
['/app/parent/contact', 'bi bi-telephone', 'Contact Us'],
] as const
return (
<nav className="bg-light sidebar ci-parent-sidebar">
<div className="sidebar-sticky pt-3">
<ul className="nav flex-column">
{links.map(([to, icon, label]) => (
<li className="nav-item" key={to}>
<Link className="nav-link" to={to}>
<i className={icon} aria-hidden /> {label}
</Link>
</li>
))}
</ul>
</div>
</nav>
)
}
export function CiAssignClassTeacherModal({
teacherName = '',
teacherRole = '',
classes,
onSubmit,
}: {
teacherName?: string
teacherRole?: string
classes: Array<{ id: number | string; name: string }>
onSubmit?: (classSectionId: string) => void
}) {
const [classSectionId, setClassSectionId] = useState('')
return (
<div className="modal d-block ci-static-modal" tabIndex={-1} role="dialog" aria-modal="true">
<div className="modal-dialog">
<div className="modal-content">
<form
onSubmit={(event) => {
event.preventDefault()
if (classSectionId) onSubmit?.(classSectionId)
}}
>
<div className="modal-header"><h5 className="modal-title">Assign Class</h5></div>
<div className="modal-body">
<ReadonlyInput label="Name" value={teacherName} />
<ReadonlyInput label="Role" value={teacherRole} />
<div className="mb-3">
<label className="form-label">Select Class</label>
<select className="form-control" value={classSectionId} onChange={(event) => setClassSectionId(event.target.value)} required>
<option value="">Select a class</option>
{classes.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
</div>
<div className="modal-footer">
<button type="submit" className="btn btn-success">Save</button>
</div>
</form>
</div>
</div>
</div>
)
}
export function CiAssignClassStudentModal({
studentName = '',
classes,
onSubmit,
}: {
studentName?: string
classes: Array<{ id: number | string; name: string }>
onSubmit?: (classSectionId: string) => void
}) {
const [classSectionId, setClassSectionId] = useState('')
return (
<div className="modal d-block ci-static-modal" tabIndex={-1} role="dialog" aria-modal="true">
<div className="modal-dialog">
<div className="modal-content">
<form
onSubmit={(event) => {
event.preventDefault()
if (classSectionId) onSubmit?.(classSectionId)
}}
>
<div className="modal-header"><h5 className="modal-title">Assign Class to Student</h5></div>
<div className="modal-body">
<ReadonlyInput label="Student Name" value={studentName} />
<div className="mb-3">
<label className="form-label">Select Class</label>
<select className="form-control" value={classSectionId} onChange={(event) => setClassSectionId(event.target.value)} required>
<option value="">Select a class</option>
{classes.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
</div>
<div className="modal-footer">
<button type="submit" className="btn btn-primary">Assign Class</button>
</div>
</form>
</div>
</div>
</div>
)
}
type AttendanceNoticePayload = {
student_name?: string
class_section_name?: string
date?: string
date_fmt?: string
reason?: string
teacher_name?: string
school_name?: string
}
export function buildAttendanceNoticeSubject(subjectType: string, payload: AttendanceNoticePayload) {
const base = payload.student_name ? ` - ${payload.student_name}` : ''
const dateLabel = payload.date_fmt || formatNoticeDate(payload.date)
const date = dateLabel ? ` (${dateLabel})` : ''
if (subjectType === 'Absent') return `Attendance Notice: Absent${base}${date}`
if (subjectType === 'Late') return `Attendance Notice: Late${base}${date}`
return `Attendance Notice${base}${date}`
}
export function buildAttendanceNoticeMessage(subjectType: string, payload: AttendanceNoticePayload) {
const name = payload.student_name || 'your child'
const date = payload.date_fmt || formatNoticeDate(payload.date) || ''
const cls = payload.class_section_name ? ` (${payload.class_section_name})` : ''
const reason = payload.reason ? ` Reason: ${payload.reason}.` : ''
const teacher = payload.teacher_name ? `\nTeacher: ${payload.teacher_name}` : ''
const school = payload.school_name || 'Our School'
if (subjectType === 'Absent') {
return `Dear Parent/Guardian,\n\nThis is to inform you that ${name}${cls} was marked Absent on ${date}.${reason}\n\nIf this was an error or there is additional context you would like to share, please reply to this email.\n\nThank you,\n${school}${teacher}\n`
}
if (subjectType === 'Late') {
return `Dear Parent/Guardian,\n\nThis is to notify you that ${name}${cls} was marked Late on ${date}.${reason}\n\nPlease help ensure timely arrival. If you have questions, reply to this email.\n\nThank you,\n${school}${teacher}\n`
}
return `Dear Parent/Guardian,\n\nWe are reaching out regarding ${name}${cls} on ${date}. Please see attendance details in the subject line.\n\nIf you have any questions, reply to this email.\n\nThank you,\n${school}${teacher}\n`
}
export function CiAttendanceNotificationModal({
payload = {},
onSubmit,
}: {
payload?: AttendanceNoticePayload & { student_id?: number | string; parent_email?: string }
onSubmit?: (values: { to: string; subjectType: string; subject: string; message: string }) => Promise<void> | void
}) {
const [subjectType, setSubjectType] = useState('Absent')
const [to, setTo] = useState(payload.parent_email ?? '')
const [subject, setSubject] = useState(() => buildAttendanceNoticeSubject('Absent', payload))
const [message, setMessage] = useState(() => buildAttendanceNoticeMessage('Absent', payload))
function updateType(nextType: string) {
setSubjectType(nextType)
setSubject(buildAttendanceNoticeSubject(nextType, payload))
setMessage(buildAttendanceNoticeMessage(nextType, payload))
}
async function submit(event: FormEvent) {
event.preventDefault()
await onSubmit?.({ to, subjectType, subject, message })
}
return (
<div className="modal d-block ci-static-modal" tabIndex={-1} role="dialog" aria-modal="true">
<div className="modal-dialog modal-lg">
<div className="modal-content">
<form onSubmit={submit}>
<div className="modal-header"><h5 className="modal-title">Notify Parent</h5></div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">To (Parent Email)</label>
<input type="email" className="form-control" value={to} onChange={(event) => setTo(event.target.value)} required />
</div>
<div className="row g-3">
<div className="col-md-5">
<label className="form-label">Subject Type</label>
<select className="form-select" value={subjectType} onChange={(event) => updateType(event.target.value)} required>
<option value="Absent">Absent Notice</option>
<option value="Late">Late/Tardy Notice</option>
<option value="General">General Attendance Message</option>
</select>
</div>
<div className="col-md-7">
<label className="form-label">Subject</label>
<input className="form-control" value={subject} onChange={(event) => setSubject(event.target.value)} required />
</div>
</div>
<div className="mt-3">
<label className="form-label">Message</label>
<textarea className="form-control" rows={8} value={message} onChange={(event) => setMessage(event.target.value)} required />
</div>
</div>
<div className="modal-footer">
<button type="submit" className="btn btn-primary">Send Notification</button>
</div>
</form>
</div>
</div>
</div>
)
}
function Checklist({
title,
name,
options,
defaultSelected,
}: {
title: string
name: string
options: string[]
defaultSelected: Set<string>
}) {
const [showOther, setShowOther] = useState(defaultSelected.has('Other'))
return (
<div className="col-md-6 mb-3">
<label className="form-label">
{title} <span className="text-danger">*</span>
</label>
<div className="border rounded p-2 bg-white ps-3 ci-checklist">
{options.map((option) => (
<div className="form-check" key={option}>
<input
className="form-check-input"
type="checkbox"
name={name}
value={option}
defaultChecked={defaultSelected.has(option)}
onChange={(event) => {
if (option === 'Other') setShowOther(event.target.checked)
}}
/>
<label className="form-check-label ms-1">{option}</label>
</div>
))}
</div>
{showOther ? <input type="text" className="form-control mt-2" name={`${name}_other`} placeholder="Please specify" /> : null}
</div>
)
}
function display(value: unknown) {
return value == null || value === '' ? '-' : String(value)
}
function ReadonlyInput({ label, value }: { label: string; value: string }) {
return (
<div className="mb-3">
<label className="form-label">{label}</label>
<input type="text" className="form-control" value={value} readOnly />
</div>
)
}
function userInitials(name?: string | null, email?: string | null) {
const cleanName = name?.trim()
if (cleanName) {
const parts = cleanName.split(/\s+/)
return `${parts[0]?.[0] ?? ''}${parts.length > 1 ? parts[parts.length - 1]?.[0] ?? '' : ''}`.toUpperCase() || 'U'
}
return email?.trim()?.[0]?.toUpperCase() || 'U'
}
function humanRole(value: string) {
return value.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
}
function formatNoticeDate(value?: string) {
if (!value) return ''
const date = new Date(String(value).replace(' ', 'T'))
if (Number.isNaN(date.valueOf())) return String(value)
const pad = (num: number) => String(num).padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${date.getFullYear()}`
}
+356 -3
View File
@@ -5,6 +5,12 @@
--mgmt-text: #1f2937;
--mgmt-muted: #64748b;
--mgmt-bg: #f5f7fb;
--mgmt-surface: #ffffff;
--mgmt-menu-bg: #14532d;
--mgmt-menu-text: rgba(255, 255, 255, 0.95);
--mgmt-thead-bg: #ecfdf3;
--mgmt-thead-border: rgba(34, 160, 71, 0.25);
--mgmt-sticky-top: 57px;
font-family:
Heebo,
@@ -23,6 +29,7 @@ html,
body {
margin: 0;
min-height: 100%;
overflow-x: hidden;
}
#root {
@@ -46,7 +53,9 @@ body {
}
.management-app .navbar {
background-color: #14532d !important;
background-color: var(--mgmt-menu-bg) !important;
color: var(--mgmt-menu-text) !important;
z-index: 1045;
}
.management-app .navbar .navbar-brand,
@@ -56,11 +65,278 @@ body {
.management-main {
background: transparent;
padding: 24px;
min-height: calc(100svh - var(--mgmt-sticky-top));
}
.management-app .nav-link.active {
background-color: rgba(34, 160, 71, 0.12);
color: var(--mgmt-primary) !important;
background-color: transparent;
}
.management-app .nav-link {
color: var(--mgmt-menu-text);
}
.management-app .nav-label-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.55rem;
border-radius: 9999px;
border: 1px solid var(--mgmt-primary);
background-color: var(--mgmt-primary);
color: #fff !important;
line-height: 1.1;
font-weight: 500;
overflow-wrap: anywhere;
}
.management-app .nav-link.active .nav-label-chip,
.management-app .nav-link:hover .nav-label-chip {
background-color: var(--mgmt-primary-hover);
border-color: var(--mgmt-primary-hover);
}
.main-layout-app .custom-navbar {
background: var(--mgmt-menu-bg) !important;
color: var(--mgmt-menu-text) !important;
}
.main-layout-app .custom-navbar .container-fluid {
align-items: center;
}
.main-layout-app .custom-navbar .navbar-brand,
.main-layout-app .custom-navbar .navbar-text,
.main-layout-app .custom-navbar .nav-link {
color: var(--mgmt-menu-text) !important;
}
.main-layout-logo {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 50%;
background: #fff;
border: 2px solid rgba(255, 255, 255, 0.75);
flex: 0 0 auto;
}
.main-layout-app .main-layout-menu > .nav,
.main-layout-app .main-layout-menu > ul {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.main-layout-app .nav-label-chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.45rem;
border-radius: 9999px;
color: var(--mgmt-menu-text);
line-height: 1.1;
}
.main-layout-app .nav-link.active .nav-label-chip,
.main-layout-app .nav-link:hover .nav-label-chip {
background: rgba(255, 255, 255, 0.16);
}
.main-layout-app .parent-navbar-menu {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
overflow-x: visible;
overflow-y: auto;
max-width: 100%;
max-height: calc(100svh - 96px);
scrollbar-width: thin;
}
.main-layout-app .parent-navbar-link {
width: 100%;
min-height: 44px;
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.6rem;
padding: 0.7rem 0.85rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: var(--mgmt-menu-text) !important;
font-size: 0.9rem;
line-height: 1.1;
text-align: left;
white-space: normal;
}
.main-layout-app .parent-navbar-link i {
font-size: 1rem;
line-height: 1;
}
.main-layout-app .parent-navbar-link.active,
.main-layout-app .parent-navbar-link:hover {
background: rgba(0, 0, 0, 0.12);
}
@media (min-width: 992px) {
.main-layout-app .main-layout-menu,
.main-layout-app .main-layout-menu > .nav,
.main-layout-app .main-layout-menu > ul {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
}
.main-layout-app .main-layout-menu ul ul {
display: none;
}
.main-layout-app .parent-navbar-menu {
width: auto;
flex-direction: row;
align-items: stretch;
overflow-x: auto;
overflow-y: visible;
max-height: none;
}
.main-layout-app .parent-navbar-link {
width: auto;
min-width: 96px;
min-height: 72px;
flex-direction: column;
justify-content: center;
gap: 0.25rem;
padding: 0.45rem 0.6rem;
border-top: 0;
border-left: 1px solid rgba(0, 0, 0, 0.18);
border-right: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0;
font-size: 0.78rem;
text-align: center;
}
}
.wrapper {
position: relative;
flex: 1 1 auto;
background: transparent;
}
.mgmt-sidebar {
position: fixed;
left: 0;
top: var(--mgmt-sticky-top);
width: 260px;
height: calc(100vh - var(--mgmt-sticky-top));
background-color: var(--mgmt-menu-bg);
color: var(--mgmt-menu-text);
border-right: 1px solid rgba(2, 6, 23, 0.08);
box-shadow: 0 2px 12px rgba(2, 6, 23, 0.12);
transform: translateX(-100%);
transition: transform 160ms ease-in-out;
z-index: 1044;
}
.management-app.mgmt-sidebar-open .mgmt-sidebar,
.mgmt-sidebar:hover {
transform: translateX(0);
}
.mgmt-sidebar-content {
height: 100%;
overflow-y: auto;
padding: 8px;
}
.mgmt-sidebar-toggle {
position: fixed;
left: 10px;
top: calc(var(--mgmt-sticky-top) + 12px);
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 9999px;
background-color: var(--mgmt-primary);
color: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
z-index: 1060;
}
.mgmt-sidebar-toggle:hover {
background-color: var(--mgmt-primary-hover);
}
.management-app.mgmt-sidebar-open .mgmt-sidebar-toggle i {
transform: rotate(180deg);
}
.mgmt-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
border: 0;
z-index: 1043;
}
@media (min-width: 992px) {
.mgmt-sidebar-backdrop {
display: none;
}
}
.management-app table:not(.no-mgmt-style) {
width: 100%;
}
.management-app table:not(.no-mgmt-style) thead th,
.management-app table:not(.no-mgmt-style) thead td {
background-color: var(--mgmt-thead-bg) !important;
color: var(--mgmt-text);
border-bottom: 1px solid var(--mgmt-thead-border);
}
.management-app .table-responsive {
width: 100%;
position: relative;
}
.management-app .card {
border: 1px solid rgba(2, 6, 23, 0.06);
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(2, 6, 23, 0.04);
}
.footer-back {
color: var(--mgmt-muted);
}
.ci-academic-filter__year {
min-width: 180px;
}
.ci-academic-filter__semester {
min-width: 140px;
}
.ci-checklist {
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
}
.ci-static-modal {
background: rgba(15, 23, 42, 0.35);
}
.login-page,
@@ -72,6 +348,83 @@ body {
background: linear-gradient(135deg, #14532d 0%, #166534 45%, #15803d 100%);
}
.parent-calendar-dot {
display: inline-block;
width: 12px;
height: 12px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
margin-right: 6px;
}
.parent-calendar-dot-general {
background: #3788d8;
}
.parent-calendar-dot-no-school {
background: #ffc107;
}
.parent-calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(84px, 1fr));
overflow-x: auto;
}
.parent-calendar-cell {
min-height: 82px;
padding: 0.45rem;
border: 1px solid #dee2e6;
background: #fff;
}
.parent-calendar-head {
min-height: auto;
background: #f8f9fa;
font-weight: 600;
text-align: center;
}
.parent-calendar-event {
display: block;
margin-top: 0.35rem;
padding: 0.25rem;
border-radius: 4px;
background: #3788d8;
color: #fff;
font-size: 0.8rem;
text-align: center;
}
.parent-calendar-event-warning {
background: #ffc107;
color: #1f2937;
}
.parent-circle {
width: 148px;
height: 148px;
border-radius: 50%;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 1rem;
text-align: center;
text-decoration: none;
box-shadow: 0 0.5rem 1rem rgba(15, 23, 42, 0.12);
}
.parent-circle strong {
line-height: 1.15;
}
.parent-circle small {
max-width: 118px;
overflow-wrap: anywhere;
}
/* Preserve docs styling from App.css */
.docs-root {
min-height: 100vh;
+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react'
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthProvider'
const parentNavItems = [
{ to: '/app/parent/home', icon: 'bi bi-house-door', label: 'Dashboard' },
{ to: '/app/parent/attendance', icon: 'bi bi-calendar-check', label: 'Attendance' },
{ to: '/app/parent/calendar', icon: 'bi bi-calendar2-week', label: 'Calendar' },
{ to: '/app/parent/progress', icon: 'bi bi-card-checklist', label: 'Class Progress' },
{ to: '/app/parent/enroll-classes', icon: 'bi bi-pencil-square', label: 'Enroll in Classes' },
{ to: '/app/parent/events', icon: 'bi bi-bell', label: 'Events' },
{ to: '/app/parent/payment', icon: 'bi bi-credit-card', label: 'Invoice' },
{ to: '/app/parent/register-student', icon: 'bi bi-person-plus', label: 'Register Students' },
{ to: '/app/parent/report-attendance', icon: 'bi bi-megaphone', label: 'Report Absence' },
{ to: '/app/parent/report-cards', icon: 'bi bi-file-earmark-text', label: 'Report Cards' },
{ to: '/app/parent/scores', icon: 'bi bi-clipboard-data', label: 'Scores' },
] as const
export function MainLayout() {
const { user, logout, selectedRole, roles } = useAuth()
const navigate = useNavigate()
const [navOpen, setNavOpen] = useState(false)
const role = selectedRole ?? roles[0] ?? ''
const dashboardPath =
role === 'parent'
? '/app/parent/home'
: role === 'teacher' || role === 'teacher_assistant'
? '/app/teacher/dashboard'
: '/app/home'
return (
<div className="main-layout-app d-flex flex-column min-vh-100">
<header className="navbar navbar-expand-lg custom-navbar sticky-top shadow-sm px-3">
<div className="container-fluid">
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
<img className="main-layout-logo" src="/logo.png" alt="Al Rahma Sunday School logo" />
Al Rahma Sunday School
</Link>
<button
className="navbar-toggler"
type="button"
aria-controls="mainLayoutNavbar"
aria-expanded={navOpen}
aria-label="Toggle navigation"
onClick={() => setNavOpen((open) => !open)}
>
<span className="navbar-toggler-icon" />
</button>
<div id="mainLayoutNavbar" className={`collapse navbar-collapse ${navOpen ? 'show' : ''}`}>
<nav className="ms-lg-auto me-lg-3 my-3 my-lg-0 main-layout-menu parent-navbar-menu" aria-label="Parent navigation">
{parentNavItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-link parent-navbar-link ${isActive ? 'active fw-semibold' : ''}`}
onClick={() => setNavOpen(false)}
>
<i className={item.icon} aria-hidden />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="d-flex align-items-center gap-2">
<span className="navbar-text small">{user?.name}</span>
<button
type="button"
className="btn btn-outline-light btn-sm"
onClick={() => {
logout()
navigate('/login', { replace: true })
}}
>
Log out
</button>
</div>
</div>
</div>
</header>
<main className="container mt-4 flex-grow-1">
<Outlet />
</main>
<footer className="footer py-4 mt-auto border-top">
<div className="container text-muted small d-flex flex-wrap justify-content-between gap-2">
<span>Al Rahma Sunday School</span>
<span>Parent and Teacher Portal</span>
</div>
</footer>
<div className="modal fade" id="familyCardModal" tabIndex={-1} aria-hidden="true">
<div className="modal-dialog modal-xl modal-dialog-scrollable">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Family</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div className="modal-body p-0" id="familyCardContent" />
</div>
</div>
</div>
</div>
)
}
+66 -16
View File
@@ -50,7 +50,7 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
const linkInner = (
<>
{icon}
{label}
<span className="nav-label-chip">{label}</span>
</>
)
@@ -90,11 +90,11 @@ function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
<li>
{main}
{children.length > 0 ? (
<div className="mt-1">
<ul className="nav flex-column gap-1 ps-0 mb-0 mt-1 list-unstyled">
{children.map((ch) => (
<NavBranch key={ch.id} item={ch} depth={depth + 1} />
))}
</div>
</ul>
) : null}
</li>
)
@@ -105,6 +105,7 @@ export function ManagementLayout() {
const navigate = useNavigate()
const [items, setItems] = useState<NavItem[]>([])
const [menuError, setMenuError] = useState<string | null>(null)
const [sidebarOpen, setSidebarOpen] = useState(false)
useEffect(() => {
let cancelled = false
@@ -129,8 +130,8 @@ export function ManagementLayout() {
}, [])
return (
<div className="management-app d-flex flex-column min-vh-100">
<header className="navbar navbar-dark border-bottom shadow-sm px-3 py-2">
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
<div className="container-fluid px-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<Link className="navbar-brand fw-semibold mb-0" to="/app/home">
Al Rahma Sunday School
@@ -151,12 +152,31 @@ export function ManagementLayout() {
</div>
</header>
<div className="container-fluid flex-grow-1 py-3">
<div className="row g-3">
<aside className="col-12 col-md-3 col-xl-2">
<div className="border rounded bg-white shadow-sm">
<div className="p-3 small text-muted border-bottom">Navigation</div>
<nav className="p-2 pb-3">
<button
id="mgmtSidebarToggle"
className="mgmt-sidebar-toggle"
type="button"
aria-label="Toggle sidebar"
aria-expanded={sidebarOpen}
onClick={() => setSidebarOpen((open) => !open)}
>
<i className="bi bi-chevron-right" aria-hidden />
</button>
{sidebarOpen ? (
<button
className="mgmt-sidebar-backdrop"
type="button"
aria-label="Close navigation"
onClick={() => setSidebarOpen(false)}
/>
) : null}
<div className="wrapper flex-grow-1">
<aside id="navbarManagement" className="mgmt-sidebar" data-mgmt-menu-mode="dark">
<div className="mgmt-sidebar-content">
<div className="px-2 py-2 small text-muted border-bottom mb-2">Navigation</div>
<nav className="pb-3">
<NavLink
to="/app/home"
className={({ isActive }) =>
@@ -164,7 +184,7 @@ export function ManagementLayout() {
}
>
<i className="bi bi-house-door me-2" aria-hidden />
Home
<span className="nav-label-chip">Home</span>
</NavLink>
<NavLink
to="/app/browse"
@@ -173,7 +193,7 @@ export function ManagementLayout() {
}
>
<i className="bi bi-diagram-3 me-2" aria-hidden />
CI views
<span className="nav-label-chip">CI views</span>
</NavLink>
{menuError ? (
<div className="alert alert-warning small py-2">{menuError}</div>
@@ -182,11 +202,41 @@ export function ManagementLayout() {
)}
</nav>
</div>
</aside>
</aside>
<main className="col-12 col-md-9 col-xl-10 management-main">
<main className="content management-main">
<Outlet />
</main>
</main>
</div>
<footer className="footer-back border-top bg-white py-3 mt-auto">
<div className="container-fluid px-4 text-muted small d-flex flex-wrap justify-content-between gap-2">
<span>Al Rahma Sunday School</span>
<span>Management Portal</span>
</div>
</footer>
<div className="modal fade" id="familyCardModal" tabIndex={-1} aria-hidden="true">
<div className="modal-dialog modal-xl modal-dialog-scrollable">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Family</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div className="modal-body p-0" id="familyCardContent" />
</div>
</div>
</div>
<div className="modal fade" id="paymentsModal" tabIndex={-1} aria-hidden="true">
<div className="modal-dialog modal-lg modal-dialog-scrollable">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Payments</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div className="modal-body p-0" id="paymentsModalContent" />
</div>
</div>
</div>
</div>
+2 -1
View File
@@ -1,7 +1,8 @@
/** Prefix for Laravel API when the SPA is served from another origin (production). Empty in dev with Vite proxy. */
export function apiOrigin(): string {
const v = import.meta.env.VITE_API_ORIGIN
return typeof v === 'string' ? v.replace(/\/$/, '') : ''
const fallback = 'https://api.alrahmaisgl.org'
return typeof v === 'string' && v.trim() ? v.replace(/\/$/, '') : fallback
}
export function apiUrl(path: string): string {
+3
View File
@@ -18,9 +18,12 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
const shortcuts: Record<string, string> = {
'landing_page/guest_dashboard': '/app/home',
'landing_page/parent_dashboard': '/app/parent/home',
'teacher/dashboard': '/app/teacher/dashboard',
'administrator/dashboard': '/app/administrator/dashboard',
'administrator/administratordashboard': '/app/admin/dashboard',
'nav-builder': '/app/nav-builder',
'api/nav-builder': '/app/nav-builder',
'parent/parent_dashboard': '/app/parent/home',
parent_dashboard: '/app/parent/home',
'parent/attendance-reports': '/app/parent/attendance-reports',
+1 -2
View File
@@ -94,8 +94,7 @@ export function AppHome() {
<li>
<Link to="/docs">Public API docs (Swagger)</Link>
</li>
<li>
<Link to="/app/browse">All CodeIgniter views (sitemap)</Link> open any{' '}
<li>
<code>app/Views</code> screen as an <code>/app/</code> parity route
</li>
</ul>
+2 -9
View File
@@ -9,8 +9,6 @@ export function CiPlaceholderPage() {
const meta = getCiMeta(star)
const title = meta?.title ?? 'Section placeholder'
const viewsPath = meta?.ciViewPath ?? (star ? `…/${star}` : '…')
return (
<div>
<nav aria-label="breadcrumb">
@@ -31,12 +29,7 @@ export function CiPlaceholderPage() {
</nav>
<h2 className="h4 mb-3">{title}</h2>
<p className="text-muted mb-2">
CodeIgniter view:{' '}
<code>
app/Views/{viewsPath}
</code>
</p>
<p className="text-muted small mb-3">
Implement this screen using the matching routes in <code>app_laravel/routes/api.php</code>{' '}
(JWT <code>Authorization: Bearer</code>). See also <Link to="/docs">API docs</Link>.
@@ -47,7 +40,7 @@ export function CiPlaceholderPage() {
<p className="small mb-0">
<Link to="/app/browse">Browse all CI view routes</Link>
{' · '}
<Link to="/app/home">App home</Link>
</p>
</div>
)
-1
View File
@@ -30,7 +30,6 @@ export function CiSitemapPage() {
</ol>
</nav>
<h2 className="h4 mb-2">CodeIgniter views SPA routes</h2>
<p className="text-muted small mb-4">
Generated from <code>app_codeigniter/app/Views/**/*.php</code>. Each link opens the parity
route under <code>/app/</code>; unimplemented screens use the section placeholder with the
+8
View File
@@ -31,8 +31,16 @@ export function LoginPage() {
setError(result.message)
return
}
if (result.requiresRoleSelection) {
navigate('/app/select-role', { replace: true, state: { from } })
return
}
const isParentLogin =
result.selectedRole?.toLowerCase() === 'parent' ||
result.roles.some((role) => role.toLowerCase() === 'parent')
let target = from.startsWith('/app') ? from : '/app/home'
if (isParentLogin && target === '/app/home') target = '/app/parent/home'
try {
const dash = await fetchDashboardRoute()
const route = dash.data?.dashboard?.route
+382
View File
@@ -0,0 +1,382 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import {
deleteNavBuilderItem,
fetchNavBuilderData,
reorderNavBuilderItems,
saveNavBuilderItem,
} from '../api/session'
import type { NavBuilderData, NavBuilderItem } from '../api/types'
import { CiFlashMessages, type CiFlashMessage } from '../components/CiPartials'
type NavFormState = {
id: string
menu_parent_id: string
label: string
url: string
icon_class: string
target: string
sort_order: string
is_enabled: boolean
roles: string[]
}
const blankForm: NavFormState = {
id: '',
menu_parent_id: '',
label: '',
url: '',
icon_class: '',
target: '',
sort_order: '0',
is_enabled: true,
roles: [],
}
export function NavBuilderPage() {
const [payload, setPayload] = useState<NavBuilderData>({
items: [],
roles: [],
parentOptions: [],
})
const [form, setForm] = useState<NavFormState>(blankForm)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [messages, setMessages] = useState<CiFlashMessage[]>([])
async function load() {
setLoading(true)
try {
const res = await fetchNavBuilderData()
setPayload(res.data?.data ?? { items: [], roles: [], parentOptions: [] })
setMessages([])
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Failed to load menu items.' }])
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const filteredParentOptions = useMemo(
() => payload.parentOptions.filter((option) => String(option.id) !== form.id),
[form.id, payload.parentOptions],
)
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
setMessages([])
try {
await saveNavBuilderItem({
id: form.id ? Number(form.id) : null,
menu_parent_id: form.menu_parent_id ? Number(form.menu_parent_id) : null,
label: form.label.trim(),
url: form.url.trim() || null,
icon_class: form.icon_class.trim() || null,
target: form.target.trim() || null,
sort_order: Number(form.sort_order) || 0,
is_enabled: form.is_enabled,
roles: form.roles.map(Number).filter((id) => id > 0),
})
setForm(blankForm)
setMessages([{ type: 'success', message: 'Menu saved.' }])
await load()
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to save menu item.' }])
} finally {
setSaving(false)
}
}
async function onDelete(item: NavBuilderItem) {
if (!window.confirm(`Delete "${item.label}"?`)) return
setMessages([])
try {
await deleteNavBuilderItem(item.id)
setMessages([{ type: 'success', message: 'Menu item deleted.' }])
if (form.id === String(item.id)) setForm(blankForm)
await load()
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to delete menu item.' }])
}
}
async function onSaveOrder() {
const orders = Object.fromEntries(
payload.items.map((item) => [String(item.id), Number(item.sort_order ?? item.order ?? 0)]),
)
setMessages([])
try {
await reorderNavBuilderItems(orders)
setMessages([{ type: 'success', message: 'Menu order saved.' }])
await load()
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to reorder menu items.' }])
}
}
function editItem(item: NavBuilderItem) {
setForm({
id: String(item.id),
menu_parent_id: item.menu_parent_id == null ? '' : String(item.menu_parent_id),
label: item.label ?? '',
url: item.url ?? '',
icon_class: item.icon_class ?? '',
target: item.target ?? '',
sort_order: String(item.sort_order ?? item.order ?? 0),
is_enabled: Boolean(item.is_enabled ?? item.enabled),
roles: item.roles?.ids?.map(String) ?? [],
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="container-fluid px-0 nav-builder-page">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h3 className="mb-1">Navbar Builder</h3>
</div>
<button className="btn btn-outline-secondary btn-sm" type="button" onClick={() => void load()}>
Refresh
</button>
</div>
<CiFlashMessages messages={messages} />
<div className="card mb-4 rounded-0 border-0" id="menuTableCard">
<div className="card-header d-flex justify-content-between align-items-center">
<span>Current Menu</span>
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void onSaveOrder()}>
Save order
</button>
</div>
<div className="card-body">
<div id="menuTableWrap" className="table-responsive">
<table id="menuTable" className="table table-striped table-hover align-middle">
<thead>
<tr>
<th>Actions</th>
<th>Label</th>
<th>URL</th>
<th>Parent</th>
<th>Roles</th>
<th>Order</th>
<th>Enabled</th>
<th>Depth</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="text-center text-muted">Loading menu...</td>
</tr>
) : payload.items.length === 0 ? (
<tr>
<td colSpan={8} className="text-center text-muted">No menu items found.</td>
</tr>
) : (
payload.items.map((item) => (
<tr key={item.id}>
<td className="text-nowrap">
<button className="btn btn-sm btn-outline-primary me-1" type="button" onClick={() => editItem(item)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" type="button" onClick={() => void onDelete(item)}>
Delete
</button>
</td>
<td>
<span className="text-muted">{'- '.repeat(item.depth ?? 0)}</span>
{item.label}
{item.icon_class ? <code className="ms-2 small">{item.icon_class}</code> : null}
</td>
<td>{item.url ? <code>{item.url}</code> : <span className="text-muted">-</span>}</td>
<td>{parentLabel(item, payload.items)}</td>
<td>
{(item.roles?.names ?? []).length > 0 ? (
item.roles?.names?.map((roleName) => (
<span key={roleName} className="badge badge-role me-1 mb-1">
{roleName}
</span>
))
) : (
<span className="text-muted">-</span>
)}
</td>
<td>
<input
className="form-control form-control-sm nav-builder-order"
type="number"
value={item.sort_order ?? item.order ?? 0}
onChange={(event) => {
const next = Number(event.target.value) || 0
setPayload((current) => ({
...current,
items: current.items.map((row) => (row.id === item.id ? { ...row, sort_order: next } : row)),
}))
}}
/>
</td>
<td>
{Boolean(item.is_enabled ?? item.enabled) ? (
<span className="badge bg-success">Yes</span>
) : (
<span className="badge bg-light text-dark">No</span>
)}
</td>
<td>{item.depth ?? 0}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="row">
<div className="col-lg-5">
<div className="card mb-3">
<div className="card-header">Add / Edit Item</div>
<div className="card-body">
<form onSubmit={onSubmit}>
<div className="mb-2">
<label className="form-label" htmlFor="menu_parent_id">Parent</label>
<select
name="menu_parent_id"
id="menu_parent_id"
className="form-select"
value={form.menu_parent_id}
onChange={(event) => setForm((current) => ({ ...current, menu_parent_id: event.target.value }))}
>
<option value="">- Top level -</option>
{filteredParentOptions.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="label">Label</label>
<input
className="form-control"
name="label"
id="label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
required
/>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="url">URL (relative or absolute)</label>
<input
className="form-control"
name="url"
id="url"
placeholder="e.g. administrator/student_profiles"
value={form.url}
onChange={(event) => setForm((current) => ({ ...current, url: event.target.value }))}
/>
</div>
<div className="row">
<div className="col-md-4 mb-2">
<label className="form-label" htmlFor="target">Target</label>
<select
name="target"
id="target"
className="form-select"
value={form.target}
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
>
<option value="">(default)</option>
<option value="_blank">_blank</option>
<option value="_self">_self</option>
</select>
</div>
<div className="col-md-4 mb-2">
<label className="form-label" htmlFor="sort_order">Order</label>
<input
className="form-control"
type="number"
name="sort_order"
id="sort_order"
value={form.sort_order}
onChange={(event) => setForm((current) => ({ ...current, sort_order: event.target.value }))}
/>
</div>
<div className="col-md-4 mb-2 d-flex align-items-end">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
name="is_enabled"
id="is_enabled"
checked={form.is_enabled}
onChange={(event) => setForm((current) => ({ ...current, is_enabled: event.target.checked }))}
/>
<label className="form-check-label" htmlFor="is_enabled">Enabled</label>
</div>
</div>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="icon_class">Icon class</label>
<input
className="form-control"
name="icon_class"
id="icon_class"
placeholder="e.g. fas fa-user-graduate"
value={form.icon_class}
onChange={(event) => setForm((current) => ({ ...current, icon_class: event.target.value }))}
/>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="roles">Roles (who can see)</label>
<select
name="roles"
id="roles"
className="form-select"
multiple
size={6}
value={form.roles}
onChange={(event) =>
setForm((current) => ({
...current,
roles: Array.from(event.target.selectedOptions).map((option) => option.value),
}))
}
>
{payload.roles.map((role) => (
<option key={role.id} value={role.id}>{role.name}</option>
))}
</select>
<small className="text-muted">Hold Ctrl/Cmd to select multiple.</small>
</div>
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>{' '}
<button className="btn btn-secondary" type="button" onClick={() => setForm(blankForm)}>
Reset
</button>
</form>
</div>
</div>
</div>
</div>
</div>
)
}
function parentLabel(item: NavBuilderItem, items: NavBuilderItem[]) {
if (item.parent_label) return item.parent_label
const parentId = item.menu_parent_id ?? item.parent_id
if (!parentId) return '-'
return items.find((row) => row.id === parentId)?.label ?? `#${parentId}`
}
+81
View File
@@ -0,0 +1,81 @@
import { useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { fetchDashboardRoute } from '../api/session'
import { useAuth } from '../auth/AuthProvider'
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
export function RoleSelectPage() {
const { user, roles, selectedRole, setSelectedRole } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [pickedRole, setPickedRole] = useState<string | null>(selectedRole)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
async function continueLogin() {
if (!pickedRole) {
setError('Please select a role to continue.')
return
}
setBusy(true)
setError(null)
setSelectedRole(pickedRole)
let target = from.startsWith('/app') ? from : '/app/home'
if (pickedRole.toLowerCase() === 'parent' && target === '/app/home') {
target = '/app/parent/home'
}
try {
const dash = await fetchDashboardRoute()
const route = dash.data?.dashboard?.route
const mapped = spaPathFromCiUrl(route)
if (mapped) target = mapped
} catch {
/* keep fallback target */
}
navigate(target, { replace: true })
}
return (
<div className="container py-4">
<div className="card border-0 shadow-sm mx-auto" style={{ maxWidth: 640 }}>
<div className="card-body p-4">
<h2 className="h4 mb-2">Select role</h2>
<p className="text-muted mb-3">
{user?.name ? `Signed in as ${user.name}.` : 'Signed in successfully.'} Choose a role
to continue.
</p>
<div className="list-group mb-3">
{roles.map((role) => (
<button
key={role}
type="button"
className={`list-group-item list-group-item-action text-start ${
pickedRole === role ? 'active' : ''
}`}
onClick={() => setPickedRole(role)}
>
{role}
</button>
))}
</div>
{error ? (
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
) : null}
<div className="d-grid">
<button type="button" className="btn btn-success" onClick={continueLogin} disabled={busy}>
{busy ? 'Continuing…' : 'Continue'}
</button>
</div>
</div>
</div>
</div>
)
}
+155 -59
View File
@@ -1,58 +1,127 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
fetchParentAttendanceReportsList,
fetchParentAuthorizedUsers,
} from '../../api/session'
import { ParentParityShell } from './ParentParityShell'
/** Secondary / authorized users — `GET /api/v1/parents/authorized-users`. */
export function ParentAuthorizedUsersPage() {
const [payload, setPayload] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const res = await fetchParentAuthorizedUsers()
if (cancelled) return
setPayload(JSON.stringify(res, null, 2))
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
return (
import { useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import {
fetchParentAttendanceReportsList,
fetchParentAuthorizedUsers,
inviteParentAuthorizedUser,
} from '../../api/session'
import type { ParentAttendanceReportsListResponse, ParentAuthorizedUserRow } from '../../api/types'
import { CiFlashMessages } from '../../components/CiPartials'
import { ParentParityShell } from './ParentParityShell'
/** Secondary / authorized users — `GET /api/v1/parents/authorized-users`. */
export function ParentAuthorizedUsersPage() {
const [rows, setRows] = useState<ParentAuthorizedUserRow[]>([])
const [email, setEmail] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
async function load(cancelled?: () => boolean) {
setLoading(true)
try {
const res = await fetchParentAuthorizedUsers()
if (cancelled?.()) return
setRows(res.data ?? [])
setError(null)
} catch (e) {
if (!cancelled?.()) setError(e instanceof Error ? e.message : 'Unable to load.')
} finally {
if (!cancelled?.()) setLoading(false)
}
}
useEffect(() => {
let cancelled = false
void load(() => cancelled)
return () => {
cancelled = true
}
}, [])
async function onInvite(e: FormEvent) {
e.preventDefault()
setStatus(null)
setError(null)
try {
const res = await inviteParentAuthorizedUser(email)
setStatus(res.message ?? 'Authorized user added.')
setEmail('')
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to add authorized user.')
}
}
return (
<ParentParityShell
title="Authorized users"
ciViewFile="add_second_parent.php"
legacyCiRoutes={['GET /api/v1/parents/authorized-users']}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 320, overflow: 'auto' }}>
{payload}
</pre>
)}
</ParentParityShell>
)
}
/** Submitted reports — `GET /api/v1/parents/attendance-reports`. */
export function ParentAttendanceReportsHistoryPage() {
const [payload, setPayload] = useState<string>('')
>
<CiFlashMessages
messages={[
...(status ? [{ type: 'success' as const, message: status }] : []),
...(error ? [{ type: 'warning' as const, message: error }] : []),
]}
/>
<form className="row g-2 align-items-end mb-3" onSubmit={onInvite}>
<div className="col-md-8">
<label className="form-label" htmlFor="authorized_user_email">
Email
</label>
<input
id="authorized_user_email"
type="email"
className="form-control"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="col-md-4">
<button type="submit" className="btn btn-success w-100">
Add authorized user
</button>
</div>
</form>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={3} className="text-muted">Loading</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={3} className="text-muted">No authorized users.</td>
</tr>
) : (
rows.map((row) => (
<tr key={row.id ?? row.email}>
<td>{row.id ?? '—'}</td>
<td>{row.email ?? '—'}</td>
<td>{row.created_at ?? '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</ParentParityShell>
)
}
/** Submitted reports — `GET /api/v1/parents/attendance-reports`. */
export function ParentAttendanceReportsHistoryPage() {
const [payload, setPayload] = useState<ParentAttendanceReportsListResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -61,9 +130,9 @@ export function ParentAttendanceReportsHistoryPage() {
;(async () => {
setLoading(true)
try {
const res = await fetchParentAttendanceReportsList()
if (cancelled) return
setPayload(JSON.stringify(res, null, 2))
const res = await fetchParentAttendanceReportsList()
if (cancelled) return
setPayload(res)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
@@ -82,14 +151,41 @@ export function ParentAttendanceReportsHistoryPage() {
ciViewFile="report_attendance.php"
legacyCiRoutes={['GET /api/v1/parents/attendance-reports']}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
<CiFlashMessages messages={error ? [{ type: 'warning', message: error }] : []} />
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 360, overflow: 'auto' }}>
{payload}
</pre>
)}
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Student</th>
<th>Date</th>
<th>Type</th>
<th>Status</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{(payload?.reports ?? []).length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">No attendance reports.</td>
</tr>
) : (
(payload?.reports ?? []).map((report) => (
<tr key={String(report.id ?? `${report.student_id}-${report.report_date}`)}>
<td>{`${report.firstname ?? ''} ${report.lastname ?? ''}`.trim() || '—'}</td>
<td>{String(report.report_date ?? '—')}</td>
<td>{String(report.type ?? '—')}</td>
<td>{String(report.status ?? '—')}</td>
<td>{String(report.reason ?? '—')}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<p className="small text-muted mt-2 mb-0">
New submission:{' '}
<Link to="/app/parent/report-attendance">Report attendance form</Link>
+3 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useSearchParams } from 'react-router-dom'
import { fetchParentAttendance } from '../../api/session'
import type { ParentAttendanceRow } from '../../api/types'
@@ -133,9 +133,9 @@ export function ParentAttendancePage() {
)}
<p className="small text-muted mt-4 mb-0">
<Link to="/app/parent/home">Parent dashboard</Link>
{' · '}
<Link to="/app/home">App home</Link>
</p>
</div>
)
+34 -66
View File
@@ -1,9 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchParentProfile } from '../../api/session'
import type { ParentProfileRecord } from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
import { ParentSectionNav } from './ParentSectionNav'
import {
fetchParentAttendance,
fetchParentEnrollments,
fetchParentInvoices,
fetchParentProfile,
} from '../../api/session'
import type { ParentAttendanceRow, ParentEnrollmentStudent, ParentInvoiceRow, ParentProfileRecord } from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
function displayName(p: ParentProfileRecord | null): string {
if (!p) return ''
@@ -16,6 +20,9 @@ function displayName(p: ParentProfileRecord | null): string {
export function ParentHomePage() {
const { user } = useAuth()
const [profile, setProfile] = useState<ParentProfileRecord | null>(null)
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
const [attendance, setAttendance] = useState<ParentAttendanceRow[]>([])
const [invoices, setInvoices] = useState<ParentInvoiceRow[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -24,9 +31,17 @@ export function ParentHomePage() {
;(async () => {
setLoading(true)
try {
const data = await fetchParentProfile()
const [data, enrollmentData, attendanceData, invoiceData] = await Promise.all([
fetchParentProfile(),
fetchParentEnrollments(null),
fetchParentAttendance(null),
fetchParentInvoices(null),
])
if (cancelled) return
setProfile(data.profile ?? null)
setStudents(enrollmentData.students ?? [])
setAttendance(attendanceData.attendance ?? [])
setInvoices(invoiceData.invoices ?? [])
setError(null)
} catch (e) {
if (!cancelled) {
@@ -48,15 +63,15 @@ export function ParentHomePage() {
return user?.name?.trim() || 'Parent'
}, [profile, user?.name])
const absentCount = attendance.filter((row) => row.status.toLowerCase() === 'absent').length
const balance = invoices.reduce((sum, invoice) => sum + (Number(invoice.balance) || 0), 0)
return (
<div>
<div className="text-center mb-4">
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
Parent dashboard
</h2>
<p className="text-muted small mb-0">
Parity with CodeIgniter <code>Views/parent/parent_dashboard</code>
</p>
</div>
{error ? (
@@ -64,7 +79,7 @@ export function ParentHomePage() {
{error}{' '}
<span className="text-muted">
Parent-only APIs require a parent account. Use the general{' '}
<Link to="/app/home">app home</Link> if you landed here by mistake.
if you landed here by mistake.
</span>
</div>
) : null}
@@ -72,65 +87,18 @@ export function ParentHomePage() {
{loading ? (
<p className="text-muted">Loading</p>
) : (
<div className="row g-3 mb-4">
<div className="col-md-6">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<h3 className="h6 text-muted">Account</h3>
<p className="mb-1">
<strong>{greeting}</strong>
</p>
{profile?.email ? (
<p className="small text-muted mb-0">{profile.email}</p>
) : null}
{profile?.cellphone ? (
<p className="small text-muted mb-0">{profile.cellphone}</p>
) : null}
</div>
</div>
</div>
<div className="col-md-6">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<h3 className="h6 text-muted">Address</h3>
{profile?.address_street || profile?.city || profile?.state ? (
<p className="small mb-0">
{[profile.address_street, profile.city, profile.state, profile.zip]
.filter(Boolean)
.join(', ')}
</p>
) : (
<p className="text-muted small mb-0">No address on file.</p>
)}
</div>
</div>
</div>
<div className="parent-dashboard-circles row g-4 justify-content-center text-center mb-4">
<div className="col-auto"><Link to="/parent_guide.pdf" className="parent-circle bg-info text-white"><strong>Your Quick Guide</strong></Link></div>
<div className="col-auto"><div className="parent-circle bg-primary text-white"><strong>{greeting}</strong><small>{profile?.email ?? 'Parent account'}</small></div></div>
<div className="col-auto"><Link to="/app/parent/register-student" className="parent-circle bg-success text-white"><strong>Students</strong><small>{students.length} total</small></Link></div>
<div className="col-auto"><Link to="/app/parent/attendance" className="parent-circle bg-danger text-white"><strong>Absences</strong><small>{absentCount} absent</small></Link></div>
<div className="col-auto"><Link to="/app/parent/report-attendance" className="parent-circle bg-secondary text-white"><strong>Report Absence</strong></Link></div>
<div className="col-auto"><Link to="/app/parent/scores" className="parent-circle bg-warning text-dark"><strong>Scores</strong><small>View grades</small></Link></div>
<div className="col-auto"><Link to="/app/parent/report-cards" className="parent-circle bg-dark text-white"><strong>Report Cards</strong></Link></div>
<div className="col-auto"><Link to="/app/parent/enroll-classes" className="parent-circle bg-info text-white"><strong>Enrollment</strong><small>{students.filter((s) => s.enrollment_status).length} records</small></Link></div>
<div className="col-auto"><Link to="/app/parent/payment" className="parent-circle bg-secondary text-white"><strong>Payment</strong><small>${balance.toFixed(2)}</small></Link></div>
</div>
)}
<div className="row g-3">
<div className="col-md-6">
<div className="border rounded p-3 bg-white h-100">
<h3 className="h6 mb-3">Shortcuts</h3>
<ul className="mb-0">
<li>
<Link to="/app/parent/attendance">Attendance record</Link>
</li>
<li>
<Link to="/app/home">App home</Link>
</li>
</ul>
</div>
</div>
<div className="col-md-6">
<div className="border rounded p-3 bg-white h-100">
<h3 className="h6 mb-2">All parent pages (CI parity)</h3>
<div className="small" style={{ maxHeight: 220, overflow: 'auto' }}>
<ParentSectionNav />
</div>
</div>
</div>
</div>
</div>
)
}
+10 -47
View File
@@ -1,8 +1,6 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ParentSectionNav } from './ParentSectionNav'
export type ParentParitySpec = {
import type { ReactNode } from 'react'
export type ParentParitySpec = {
title: string
/** File name under `app/Views/parent/` (e.g. `calendar.php`). */
ciViewFile: string
@@ -14,60 +12,25 @@ type ShellProps = ParentParitySpec & {
children?: ReactNode
}
export function ParentParityShell({
title,
ciViewFile,
legacyCiRoutes,
children,
}: ShellProps) {
export function ParentParityShell({
title,
children,
}: ShellProps) {
return (
<div>
<div className="text-center mb-3">
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
{title}
</h2>
<p className="text-muted small mb-0">
CodeIgniter view:{' '}
<code>
app/Views/parent/{ciViewFile}
</code>
</p>
</div>
{legacyCiRoutes?.length ? (
<p className="small text-muted mb-3">
Legacy routes:{' '}
{legacyCiRoutes.map((r, i) => (
<span key={r}>
{i > 0 ? ' · ' : null}
<code>{r}</code>
</span>
))}
</p>
) : null}
<div className="row g-3">
<aside className="col-lg-3">
<div className="border rounded bg-white shadow-sm p-2 small">
<div className="fw-semibold text-muted px-2 py-1 border-bottom mb-2">Parent pages</div>
<ParentSectionNav />
</div>
</aside>
<div className="col-lg-9">
<div className="alert alert-light border small mb-3">
This SPA route mirrors the CI parent view. Implement using the matching handlers in{' '}
<code>app_laravel/routes/api.php</code> (prefix <code>/api/v1/parents/...</code> where
available).
</div>
{children}
</div>
</div>
<p className="mt-3 small mb-0">
<Link to="/app/parent/home"> Parent dashboard</Link>
{' · '}
<Link to="/app/home">App home</Link>
</p>
</div>
</div>
)
}
+159 -50
View File
@@ -4,12 +4,19 @@ import {
fetchAttendanceReportForm,
fetchParentEnrollments,
fetchParentEventsOverview,
fetchParentInvoices,
fetchParentProfile,
fetchParentRegistrationOverview,
submitContactMessage,
} from '../../api/session'
import type { ParentEnrollmentStudent, ParentInvoiceRow } from '../../api/types'
fetchParentInvoices,
fetchParentProfile,
fetchParentRegistrationOverview,
submitParentAttendanceReport,
submitContactMessage,
} from '../../api/session'
import type {
ParentAttendanceReportFormResponse,
ParentEnrollmentStudent,
ParentEventsOverviewResponse,
ParentInvoiceRow,
ParentRegistrationOverviewResponse,
} from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
/** `Views/parent/contact.php` — POST /api/v1/contact */
@@ -300,19 +307,19 @@ export function ParentPaymentInvoicesPage() {
}
/** `Views/parent/event_participation.php` — GET/POST /api/v1/parents/events … */
export function ParentEventsPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [summary, setSummary] = useState<string>('')
export function ParentEventsPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [data, setData] = useState<ParentEventsOverviewResponse | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchParentEventsOverview()
if (cancelled) return
setSummary(JSON.stringify(data, null, 2))
const data = await fetchParentEventsOverview()
if (cancelled) return
setData(data)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load events.')
@@ -335,28 +342,43 @@ export function ParentEventsPage() {
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 320, overflow: 'auto' }}>
{summary}
</pre>
)}
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr><th>Event</th><th>Date</th><th>Description</th></tr>
</thead>
<tbody>
{(data?.activeEvents ?? []).length === 0 ? (
<tr><td colSpan={3} className="text-muted">No active events.</td></tr>
) : (data?.activeEvents ?? []).map((event) => (
<tr key={String(event.id ?? event.title ?? event.event_name)}>
<td>{event.title ?? event.event_name ?? '—'}</td>
<td>{event.event_date ?? event.start_date ?? '—'}</td>
<td>{event.description ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ParentParityShell>
)
}
/** `Views/parent/register_student.php` — GET /api/v1/parents/registration */
export function ParentRegisterStudentPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [summary, setSummary] = useState<string>('')
export function ParentRegisterStudentPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [data, setData] = useState<ParentRegistrationOverviewResponse | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchParentRegistrationOverview()
if (cancelled) return
setSummary(JSON.stringify(data, null, 2))
const data = await fetchParentRegistrationOverview()
if (cancelled) return
setData(data)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load registration.')
@@ -379,28 +401,68 @@ export function ParentRegisterStudentPage() {
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 360, overflow: 'auto' }}>
{summary}
</pre>
)}
<>
<div className="d-flex flex-wrap gap-2 mb-3">
<a className="btn btn-success btn-sm" href="/app/parent/add-student-form">Add student</a>
<a className="btn btn-outline-success btn-sm" href="/app/parent/add-emergency-form">Add emergency contact</a>
</div>
<h3 className="h6">Students</h3>
<div className="table-responsive mb-3">
<table className="table table-sm table-striped">
<thead><tr><th>Name</th><th>Grade</th><th>Class</th><th>Status</th></tr></thead>
<tbody>
{(data?.existingKids ?? []).length === 0 ? (
<tr><td colSpan={4} className="text-muted">No students registered.</td></tr>
) : (data?.existingKids ?? []).map((student) => (
<tr key={student.id}>
<td>{student.firstname} {student.lastname}</td>
<td>{student.registration_grade ?? '—'}</td>
<td>{student.class_section ?? '—'}</td>
<td>{student.admission_status ?? student.enrollment_status ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="h6">Emergency contacts</h3>
<div className="table-responsive">
<table className="table table-sm table-striped">
<thead><tr><th>Name</th><th>Phone</th><th>Email</th><th>Relation</th></tr></thead>
<tbody>
{(data?.emergencies ?? []).length === 0 ? (
<tr><td colSpan={4} className="text-muted">No emergency contacts.</td></tr>
) : (data?.emergencies ?? []).map((contact) => (
<tr key={contact.id}>
<td>{contact.name}</td>
<td>{contact.cellphone}</td>
<td>{contact.email ?? '—'}</td>
<td>{contact.relation ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</ParentParityShell>
)
}
/** `Views/parent/report_attendance.php` — form: GET /api/v1/parents/attendance-reports/form */
export function ParentReportAttendancePage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [summary, setSummary] = useState<string>('')
export function ParentReportAttendancePage() {
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [data, setData] = useState<ParentAttendanceReportFormResponse | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchAttendanceReportForm()
if (cancelled) return
setSummary(JSON.stringify(data, null, 2))
const data = await fetchAttendanceReportForm()
if (cancelled) return
setData(data)
setError(null)
} catch (e) {
if (!cancelled)
@@ -420,18 +482,65 @@ export function ParentReportAttendancePage() {
ciViewFile="report_attendance.php"
legacyCiRoutes={['/parent/report-attendance']}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 360, overflow: 'auto' }}>
{summary}
</pre>
)}
<p className="small text-muted mt-2 mb-0">
Submit payloads: <code>POST /api/v1/parents/attendance-reports</code>,{' '}
<code>PATCH /api/v1/parents/attendance-reports/{'{id}'}</code>.
</p>
</ParentParityShell>
)
}
{status ? <div className="alert alert-success small">{status}</div> : null}
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<form
className="row g-3"
onSubmit={async (e) => {
e.preventDefault()
setStatus(null)
setError(null)
const form = new FormData(e.currentTarget)
try {
const res = await submitParentAttendanceReport({
student_ids: [Number(form.get('student_id'))],
date: String(form.get('date') ?? ''),
type: String(form.get('type') ?? ''),
arrival_time: String(form.get('arrival_time') ?? ''),
dismiss_time: String(form.get('dismiss_time') ?? ''),
reason: String(form.get('reason') ?? ''),
})
setStatus(`Report submitted. Inserted: ${res.inserted ?? 0}`)
e.currentTarget.reset()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit report.')
}
}}
>
<div className="col-md-6">
<label className="form-label">Student</label>
<select name="student_id" className="form-control" required>
<option value="">Select student</option>
{Array.isArray(data?.students)
? data.students.map((student: any) => (
<option key={student.id} value={student.id}>
{student.firstname} {student.lastname}
</option>
))
: null}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Date</label>
<input name="date" type="date" className="form-control" defaultValue={String(data?.defaultDate ?? '')} required />
</div>
<div className="col-md-3">
<label className="form-label">Type</label>
<select name="type" className="form-control" required>
<option value="absent">Absent</option>
<option value="late">Late</option>
<option value="early_dismissal">Early dismissal</option>
</select>
</div>
<div className="col-md-6"><label className="form-label">Arrival time</label><input name="arrival_time" type="time" className="form-control" /></div>
<div className="col-md-6"><label className="form-label">Dismiss time</label><input name="dismiss_time" type="time" className="form-control" /></div>
<div className="col-12"><label className="form-label">Reason</label><textarea name="reason" className="form-control" rows={3} /></div>
<div className="col-12"><button className="btn btn-success" type="submit">Submit report</button></div>
</form>
)}
</ParentParityShell>
)
}
+321 -79
View File
@@ -1,25 +1,148 @@
import { Link, useParams } from 'react-router-dom'
import { ParentParityShell } from './ParentParityShell'
export function ParentProgressViewPage() {
const { progressId } = useParams<{ progressId: string }>()
return (
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
fetchClassProgressDetail,
fetchParentEmergencyContacts,
fetchParentRegistrationOverview,
fetchReportCardAcknowledgement,
fetchReportCardPdf,
updateParentEmergencyContact,
updateParentStudent,
} from '../../api/session'
import type {
ClassProgressReportRow,
ParentEmergencyContactRow,
ParentEnrollmentStudent,
ReportCardAcknowledgementRow,
} from '../../api/types'
import { CiEmergencyContactFormPartial, CiFlashMessages, CiStudentFormPartial } from '../../components/CiPartials'
import { ParentParityShell } from './ParentParityShell'
function fieldValue(value: unknown) {
return value == null || value === '' ? '—' : String(value)
}
export function ParentProgressViewPage() {
const { progressId } = useParams<{ progressId: string }>()
const id = Number(progressId)
const [reports, setReports] = useState<ClassProgressReportRow[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
let cancelled = false
;(async () => {
setLoading(true)
try {
const res = await fetchClassProgressDetail(id)
if (cancelled) return
const weekly = Object.values(res.data?.weekly_reports ?? {})
const fallback = res.data?.report ? [res.data.report] : []
setReports(weekly.length > 0 ? weekly : fallback)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load progress.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [id])
return (
<ParentParityShell
title="Class progress detail"
ciViewFile="class_progress_view.php"
legacyCiRoutes={[`/parent/progress/view/${progressId ?? ''}`]}
>
<p className="small mb-0">
Progress id: <code>{progressId}</code>. Attachment downloads map to{' '}
<code>/api/v1/class-progress/</code> in Laravel when wired.
</p>
</ParentParityShell>
)
}
export function ParentEmergencyContactEditPage() {
const { contactId } = useParams<{ contactId: string }>()
return (
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<div className="table-responsive">
<table className="table table-sm table-bordered align-middle">
<thead>
<tr>
<th>Subject</th>
<th>Unit</th>
<th>Covered</th>
<th>Homework</th>
<th>Status</th>
<th>Support</th>
</tr>
</thead>
<tbody>
{reports.length === 0 ? (
<tr><td colSpan={6} className="text-muted">No progress detail found.</td></tr>
) : reports.map((report) => (
<tr key={report.id}>
<td>{fieldValue(report.subject)}</td>
<td>{fieldValue(report.unit_title)}</td>
<td>{fieldValue(report.covered)}</td>
<td>{fieldValue(report.homework)}</td>
<td>{fieldValue(report.status_label ?? report.status)}</td>
<td>{fieldValue(report.support_needed)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ParentParityShell>
)
}
export function ParentEmergencyContactEditPage() {
const { contactId } = useParams<{ contactId: string }>()
const id = Number(contactId)
const [contact, setContact] = useState<ParentEmergencyContactRow | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const res = await fetchParentEmergencyContacts()
if (cancelled) return
setContact((res.contacts ?? []).find((row) => row.id === id) ?? null)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load contact.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [id])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!contact?.id) return
setStatus(null)
setError(null)
const form = new FormData(e.currentTarget)
try {
await updateParentEmergencyContact(contact.id, {
name: String(form.get('name') ?? ''),
cellphone: String(form.get('cellphone') ?? ''),
email: String(form.get('email') ?? ''),
relation: String(form.get('relation') ?? ''),
})
setStatus('Emergency contact updated.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update contact.')
}
}
return (
<ParentParityShell
title="Emergency contact"
ciViewFile="edit_emergency_contact.php"
@@ -28,22 +151,84 @@ export function ParentEmergencyContactEditPage() {
`/parent/edit_emergency_contact/${contactId ?? ''}`,
`/parent/edit-emergency-contact/${contactId ?? ''}`,
]}
>
<p className="small mb-2">
Contact id: <code>{contactId}</code>. Use{' '}
<code>GET/PATCH /api/v1/parents/emergency-contacts</code> when implementing the form.
</p>
<Link to="/app/parent/register-student" className="small">
Registration &amp; contacts overview
</Link>
</ParentParityShell>
)
}
>
<CiFlashMessages
messages={[
...(status ? [{ type: 'success' as const, message: status }] : []),
...(error ? [{ type: 'warning' as const, message: error }] : []),
]}
/>
{loading ? <p className="text-muted small">Loading</p> : null}
{!loading && !contact ? <div className="alert alert-info small">Contact not found.</div> : null}
{contact ? (
<form onSubmit={onSubmit}>
<CiEmergencyContactFormPartial defaultValues={contact} />
<button className="btn btn-success" type="submit">Save contact</button>
</form>
) : null}
</ParentParityShell>
)
}
/** Parity for CI student edit modal / edit_student flows (`edit_student_modal.php`). */
export function ParentStudentEditPage() {
const { studentId } = useParams<{ studentId: string }>()
return (
export function ParentStudentEditPage() {
const { studentId } = useParams<{ studentId: string }>()
const id = Number(studentId)
const [student, setStudent] = useState<ParentEnrollmentStudent | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const res = await fetchParentRegistrationOverview()
if (cancelled) return
setStudent((res.existingKids ?? []).find((row) => row.id === id) ?? null)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load student.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [id])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!student?.id) return
const form = new FormData(e.currentTarget)
setStatus(null)
setError(null)
try {
const allergies = form.getAll('allergies').map(String)
const medicalConditions = form.getAll('medical_conditions').map(String)
const allergyOther = String(form.get('allergies_other') ?? '').trim()
const medicalOther = String(form.get('medical_conditions_other') ?? '').trim()
if (allergyOther) allergies.push(allergyOther)
if (medicalOther) medicalConditions.push(medicalOther)
await updateParentStudent(student.id, {
firstname: String(form.get('firstname') ?? ''),
lastname: String(form.get('lastname') ?? ''),
dob: String(form.get('dob') ?? ''),
gender: String(form.get('gender') ?? ''),
registration_grade: String(form.get('registration_grade') ?? ''),
photo_consent: form.get('photo_consent') === 'Yes',
allergies,
medical_conditions: medicalConditions,
})
setStatus('Student updated.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update student.')
}
}
return (
<ParentParityShell
title="Edit student"
ciViewFile="edit_student_modal.php"
@@ -51,48 +236,80 @@ export function ParentStudentEditPage() {
`/parent/edit_student/${studentId ?? ''}`,
`/parent/edit-student/${studentId ?? ''}`,
]}
>
<p className="small mb-0">
Student id: <code>{studentId}</code>. Wire to{' '}
<code>PATCH /api/v1/parents/students/{'{studentId}'}</code>.
</p>
</ParentParityShell>
)
}
>
<CiFlashMessages
messages={[
...(status ? [{ type: 'success' as const, message: status }] : []),
...(error ? [{ type: 'warning' as const, message: error }] : []),
]}
/>
{loading ? <p className="text-muted small">Loading</p> : null}
{!loading && !student ? <div className="alert alert-info small">Student not found.</div> : null}
{student ? (
<form onSubmit={onSubmit}>
<CiStudentFormPartial defaultValues={student} />
<button className="btn btn-success" type="submit">Save student</button>
</form>
) : null}
</ParentParityShell>
)
}
/** CI `parent/report-cards/view/(:num)` — report card PDF / viewer. */
export function ParentReportCardsViewPage() {
const { reportId } = useParams<{ reportId: string }>()
return (
export function ParentReportCardsViewPage() {
const { reportId } = useParams<{ reportId: string }>()
const id = Number(reportId)
const [url, setUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
let objectUrl: string | null = null
;(async () => {
try {
const blob = await fetchReportCardPdf(id)
objectUrl = URL.createObjectURL(blob)
setUrl(objectUrl)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load report card.')
}
})()
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [id])
return (
<ParentParityShell
title="Report card"
ciViewFile="report_cards.php"
legacyCiRoutes={[`/parent/report-cards/view/${reportId ?? ''}`]}
>
<p className="small mb-0">
Report / student id: <code>{reportId}</code>. Laravel:{' '}
<code>/api/v1/reports/report-cards/students/{'{studentId}'}</code> (with parent auth).
</p>
</ParentParityShell>
)
}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
{!url && !error ? <p className="text-muted small">Loading report card</p> : null}
{url ? (
<iframe title="Report card" src={url} className="w-100 border rounded" style={{ minHeight: '75vh' }} />
) : null}
</ParentParityShell>
)
}
/** CI `parent/progress/attachment/(:num)` — file download route. */
export function ParentProgressAttachmentPage() {
export function ParentProgressAttachmentPage() {
const { attachmentId } = useParams<{ attachmentId: string }>()
return (
<ParentParityShell
title="Class progress attachment"
ciViewFile="class_progress_view.php"
legacyCiRoutes={[`/parent/progress/attachment/${attachmentId ?? ''}`]}
>
<p className="small mb-0">
Attachment id: <code>{attachmentId}</code>. Wire download to{' '}
<code>/api/v1/class-progress/attachments/{'{attachmentId}'}</code>.
</p>
</ParentParityShell>
)
}
>
<a className="btn btn-outline-primary" href={`/api/v1/class-progress/attachments/${attachmentId}`}>
Download attachment
</a>
</ParentParityShell>
)
}
/** CI `parent/progress/attachment-file/(:num)` — legacy file stream. */
export function ParentProgressAttachmentFilePage() {
@@ -102,28 +319,53 @@ export function ParentProgressAttachmentFilePage() {
title="Class progress attachment (legacy)"
ciViewFile="class_progress_view.php"
legacyCiRoutes={[`/parent/progress/attachment-file/${fileId ?? ''}`]}
>
<p className="small mb-0">
File id: <code>{fileId}</code>. Legacy path uses{' '}
<code>/api/v1/class-progress/{'{id}'}/attachment</code>.
</p>
</ParentParityShell>
)
}
>
<a className="btn btn-outline-primary" href={`/api/v1/class-progress/${fileId}/attachment`}>
Download legacy attachment
</a>
</ParentParityShell>
)
}
/** CI `POST parent/report-cards/sign/(:num)` — acknowledgement / signature flow. */
export function ParentReportCardsSignPage() {
const { studentId } = useParams<{ studentId: string }>()
return (
export function ParentReportCardsSignPage() {
const { studentId } = useParams<{ studentId: string }>()
const id = Number(studentId)
const [ack, setAck] = useState<ReportCardAcknowledgementRow | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
let cancelled = false
;(async () => {
try {
const res = await fetchReportCardAcknowledgement(id)
if (!cancelled) setAck(res.data ?? null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load acknowledgement.')
}
})()
return () => {
cancelled = true
}
}, [id])
return (
<ParentParityShell
title="Sign report card"
ciViewFile="report_cards.php"
legacyCiRoutes={[`POST /parent/report-cards/sign/${studentId ?? ''}`]}
>
<p className="small mb-0">
Student id: <code>{studentId}</code>. Wire <code>POST</code> to Laravel report-cards
acknowledgement endpoints under <code>/api/v1/reports/report-cards/</code>.
</p>
</ParentParityShell>
)
}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
<dl className="row small">
<dt className="col-sm-3">Student ID</dt><dd className="col-sm-9">{studentId}</dd>
<dt className="col-sm-3">Viewed</dt><dd className="col-sm-9">{fieldValue(ack?.viewed_at)}</dd>
<dt className="col-sm-3">Signed</dt><dd className="col-sm-9">{fieldValue(ack?.signed_at)}</dd>
<dt className="col-sm-3">Signed name</dt><dd className="col-sm-9">{fieldValue(ack?.signed_name)}</dd>
</dl>
<Link className="btn btn-outline-primary" to={`/app/parent/report-cards/view/${studentId}`}>
View report card
</Link>
</ParentParityShell>
)
}
+212 -29
View File
@@ -1,29 +1,212 @@
import { createParentParityPage } from './createParentParityPage'
/** CI `parent/edit-all-students` — bulk edit modal (composed from registration views). */
export const ParentEditAllStudentsPage = createParentParityPage({
title: 'Edit all students',
ciViewFile: 'register_student.php',
legacyCiRoutes: ['/parent/edit-all-students'],
})
/** CI `parent/add-emergency-form` — partial / modal flow. */
export const ParentAddEmergencyFormPage = createParentParityPage({
title: 'Add emergency contact',
ciViewFile: 'edit_emergency_contact.php',
legacyCiRoutes: ['/parent/add-emergency-form'],
})
/** CI `parent/add-student-form` — add student flow. */
export const ParentAddStudentFormPage = createParentParityPage({
title: 'Add student',
ciViewFile: 'register_student.php',
legacyCiRoutes: ['/parent/add-student-form'],
})
/** POST `parent/updateProfile/(:num)` — profile updates (PATCH in Laravel API). */
export const ParentUpdateProfileParityPage = createParentParityPage({
title: 'Update profile',
ciViewFile: 'register_student.php',
legacyCiRoutes: ['POST /parent/updateProfile/:id'],
})
import { useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import {
fetchParentProfile,
fetchParentRegistrationOverview,
storeParentEmergencyContact,
storeParentRegistration,
updateParentProfile,
} from '../../api/session'
import type { ParentEnrollmentStudent, ParentProfileRecord } from '../../api/types'
import { CiEmergencyContactFormPartial, CiFlashMessages, CiStandardStudentTable, CiStudentFormPartial } from '../../components/CiPartials'
import { ParentParityShell } from './ParentParityShell'
function Notice({ message, type = 'success' }: { message: string | null; type?: 'success' | 'warning' }) {
return message ? <CiFlashMessages messages={[{ type, message }]} /> : null
}
export function ParentEditAllStudentsPage() {
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetchParentRegistrationOverview()
if (!cancelled) setStudents(res.existingKids ?? [])
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load students.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
return (
<ParentParityShell title="Edit all students" ciViewFile="register_student.php" legacyCiRoutes={['/parent/edit-all-students']}>
<Notice message={error} type="warning" />
{loading ? (
<p className="text-muted small">Loading...</p>
) : (
<CiStandardStudentTable
rows={students.map((student) => ({
...student,
first_name: student.firstname,
last_name: student.lastname,
Grade: student.registration_grade,
}))}
customHeaders={['Class']}
customKeys={['class_section']}
emptyText="No students found."
/>
)}
{!loading && students.length > 0 ? (
<div className="d-flex flex-wrap gap-2">
{students.map((student) => (
<Link key={student.id} className="btn btn-sm btn-outline-primary" to={`/app/parent/students/${student.id}/edit`}>
Edit {student.firstname}
</Link>
))}
</div>
) : null}
</ParentParityShell>
)
}
export function ParentAddEmergencyFormPage() {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = new FormData(e.currentTarget)
setStatus(null)
setError(null)
try {
await storeParentEmergencyContact({
name: String(form.get('name') ?? ''),
cellphone: String(form.get('cellphone') ?? ''),
email: String(form.get('email') ?? ''),
relation: String(form.get('relation') ?? ''),
})
setStatus('Emergency contact added.')
e.currentTarget.reset()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add contact.')
}
}
return (
<ParentParityShell title="Add emergency contact" ciViewFile="edit_emergency_contact.php" legacyCiRoutes={['/parent/add-emergency-form']}>
<Notice message={status} />
<Notice message={error} type="warning" />
<form onSubmit={onSubmit}>
<CiEmergencyContactFormPartial />
<button className="btn btn-success" type="submit">Add contact</button>
</form>
</ParentParityShell>
)
}
export function ParentAddStudentFormPage() {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = new FormData(e.currentTarget)
setStatus(null)
setError(null)
try {
const allergies = form.getAll('allergies').map(String)
const medicalConditions = form.getAll('medical_conditions').map(String)
const allergyOther = String(form.get('allergies_other') ?? '').trim()
const medicalOther = String(form.get('medical_conditions_other') ?? '').trim()
if (allergyOther) allergies.push(allergyOther)
if (medicalOther) medicalConditions.push(medicalOther)
await storeParentRegistration({
students: [{
firstname: String(form.get('firstname') ?? ''),
lastname: String(form.get('lastname') ?? ''),
dob: String(form.get('dob') ?? ''),
gender: String(form.get('gender') ?? ''),
registration_grade: String(form.get('registration_grade') ?? ''),
photo_consent: form.get('photo_consent') === 'Yes',
is_new: true,
allergies,
medical_conditions: medicalConditions,
}],
})
setStatus('Student added.')
e.currentTarget.reset()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add student.')
}
}
return (
<ParentParityShell title="Add student" ciViewFile="register_student.php" legacyCiRoutes={['/parent/add-student-form']}>
<Notice message={status} />
<Notice message={error} type="warning" />
<form onSubmit={onSubmit}>
<CiStudentFormPartial />
<button className="btn btn-success" type="submit">Add student</button>
</form>
</ParentParityShell>
)
}
export function ParentUpdateProfileParityPage() {
const [profile, setProfile] = useState<ParentProfileRecord | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetchParentProfile()
if (!cancelled) setProfile(res.profile)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load profile.')
}
})()
return () => {
cancelled = true
}
}, [])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = new FormData(e.currentTarget)
setStatus(null)
setError(null)
try {
const res = await updateParentProfile({
firstname: String(form.get('firstname') ?? ''),
lastname: String(form.get('lastname') ?? ''),
cellphone: String(form.get('cellphone') ?? ''),
address_street: String(form.get('address_street') ?? ''),
city: String(form.get('city') ?? ''),
state: String(form.get('state') ?? ''),
zip: String(form.get('zip') ?? ''),
})
setProfile(res.profile)
setStatus('Profile updated.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile.')
}
}
return (
<ParentParityShell title="Update profile" ciViewFile="register_student.php" legacyCiRoutes={['PATCH /api/v1/parents/profile']}>
<Notice message={status} />
<Notice message={error} type="warning" />
<form className="row g-3" onSubmit={onSubmit}>
<div className="col-md-6"><label className="form-label">First name</label><input name="firstname" className="form-control" defaultValue={profile?.firstname ?? ''} required /></div>
<div className="col-md-6"><label className="form-label">Last name</label><input name="lastname" className="form-control" defaultValue={profile?.lastname ?? ''} required /></div>
<div className="col-md-6"><label className="form-label">Cellphone</label><input name="cellphone" className="form-control" defaultValue={profile?.cellphone ?? ''} required /></div>
<div className="col-md-6"><label className="form-label">Street</label><input name="address_street" className="form-control" defaultValue={profile?.address_street ?? ''} required /></div>
<div className="col-md-4"><label className="form-label">City</label><input name="city" className="form-control" defaultValue={profile?.city ?? ''} required /></div>
<div className="col-md-4"><label className="form-label">State</label><input name="state" className="form-control" defaultValue={profile?.state ?? ''} required /></div>
<div className="col-md-4"><label className="form-label">Zip</label><input name="zip" className="form-control" defaultValue={profile?.zip ?? ''} required /></div>
<div className="col-12"><button className="btn btn-success" type="submit">Save profile</button></div>
</form>
</ParentParityShell>
)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const apiTarget = process.env.VITE_PROXY_API ?? 'http://localhost:8000'
const apiTarget = process.env.VITE_PROXY_API ?? 'https://api.alrahmaisgl.org'
// https://vite.dev/config/
export default defineConfig({