add parent pages
This commit is contained in:
+1
-1
@@ -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>
|
||||
|
||||
Generated
+3596
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
+210
-63
@@ -1,74 +1,210 @@
|
||||
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>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
@@ -88,9 +224,15 @@ export default function App() {
|
||||
<Route path="/admin" element={<AdminDocs />} />
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/app" element={<ManagementLayout />}>
|
||||
<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 />} />
|
||||
@@ -164,13 +306,18 @@ export default function App() {
|
||||
<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
@@ -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
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -184,10 +204,40 @@ export function ManagementLayout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="col-12 col-md-9 col-xl-10 management-main">
|
||||
<main className="content management-main">
|
||||
<Outlet />
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -95,7 +95,6 @@ export function AppHome() {
|
||||
<Link to="/docs">Public API docs (Swagger)</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/app/browse">All CodeIgniter views (sitemap)</Link> — open any{' '}
|
||||
<code>app/Views</code> screen as an <code>/app/…</code> parity route
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 [payload, setPayload] = useState<string>('')
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
async function load(cancelled?: () => boolean) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchParentAuthorizedUsers()
|
||||
if (cancelled) return
|
||||
setPayload(JSON.stringify(res, null, 2))
|
||||
if (cancelled?.()) return
|
||||
setRows(res.data ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
if (!cancelled?.()) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
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}
|
||||
<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 ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
<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>
|
||||
) : (
|
||||
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 320, overflow: 'auto' }}>
|
||||
{payload}
|
||||
</pre>
|
||||
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<string>('')
|
||||
const [payload, setPayload] = useState<ParentAttendanceReportsListResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -63,7 +132,7 @@ export function ParentAttendanceReportsHistoryPage() {
|
||||
try {
|
||||
const res = await fetchParentAttendanceReportsList()
|
||||
if (cancelled) return
|
||||
setPayload(JSON.stringify(res, null, 2))
|
||||
setPayload(res)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
@@ -82,13 +151,40 @@ 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:{' '}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
fetchParentAttendance,
|
||||
fetchParentEnrollments,
|
||||
fetchParentInvoices,
|
||||
fetchParentProfile,
|
||||
} from '../../api/session'
|
||||
import type { ParentAttendanceRow, ParentEnrollmentStudent, ParentInvoiceRow, ParentProfileRecord } from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthProvider'
|
||||
import { ParentSectionNav } from './ParentSectionNav'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ParentSectionNav } from './ParentSectionNav'
|
||||
|
||||
export type ParentParitySpec = {
|
||||
title: string
|
||||
@@ -16,8 +14,6 @@ type ShellProps = ParentParitySpec & {
|
||||
|
||||
export function ParentParityShell({
|
||||
title,
|
||||
ciViewFile,
|
||||
legacyCiRoutes,
|
||||
children,
|
||||
}: ShellProps) {
|
||||
return (
|
||||
@@ -26,48 +22,15 @@ export function ParentParityShell({
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,16 @@ import {
|
||||
fetchParentInvoices,
|
||||
fetchParentProfile,
|
||||
fetchParentRegistrationOverview,
|
||||
submitParentAttendanceReport,
|
||||
submitContactMessage,
|
||||
} from '../../api/session'
|
||||
import type { ParentEnrollmentStudent, ParentInvoiceRow } from '../../api/types'
|
||||
import type {
|
||||
ParentAttendanceReportFormResponse,
|
||||
ParentEnrollmentStudent,
|
||||
ParentEventsOverviewResponse,
|
||||
ParentInvoiceRow,
|
||||
ParentRegistrationOverviewResponse,
|
||||
} from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthProvider'
|
||||
|
||||
/** `Views/parent/contact.php` — POST /api/v1/contact */
|
||||
@@ -303,7 +310,7 @@ export function ParentPaymentInvoicesPage() {
|
||||
export function ParentEventsPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [summary, setSummary] = useState<string>('')
|
||||
const [data, setData] = useState<ParentEventsOverviewResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -312,7 +319,7 @@ export function ParentEventsPage() {
|
||||
try {
|
||||
const data = await fetchParentEventsOverview()
|
||||
if (cancelled) return
|
||||
setSummary(JSON.stringify(data, null, 2))
|
||||
setData(data)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load events.')
|
||||
@@ -335,9 +342,24 @@ 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>
|
||||
)
|
||||
@@ -347,7 +369,7 @@ export function ParentEventsPage() {
|
||||
export function ParentRegisterStudentPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [summary, setSummary] = useState<string>('')
|
||||
const [data, setData] = useState<ParentRegistrationOverviewResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -356,7 +378,7 @@ export function ParentRegisterStudentPage() {
|
||||
try {
|
||||
const data = await fetchParentRegistrationOverview()
|
||||
if (cancelled) return
|
||||
setSummary(JSON.stringify(data, null, 2))
|
||||
setData(data)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load registration.')
|
||||
@@ -379,9 +401,48 @@ 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>
|
||||
)
|
||||
@@ -390,8 +451,9 @@ export function ParentRegisterStudentPage() {
|
||||
/** `Views/parent/report_attendance.php` — form: GET /api/v1/parents/attendance-reports/form */
|
||||
export function ParentReportAttendancePage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [summary, setSummary] = useState<string>('')
|
||||
const [data, setData] = useState<ParentAttendanceReportFormResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -400,7 +462,7 @@ export function ParentReportAttendancePage() {
|
||||
try {
|
||||
const data = await fetchAttendanceReportForm()
|
||||
if (cancelled) return
|
||||
setSummary(JSON.stringify(data, null, 2))
|
||||
setData(data)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled)
|
||||
@@ -420,18 +482,65 @@ export function ParentReportAttendancePage() {
|
||||
ciViewFile="report_attendance.php"
|
||||
legacyCiRoutes={['/parent/report-attendance']}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||
{summary}
|
||||
</pre>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,147 @@
|
||||
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>
|
||||
{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"
|
||||
@@ -29,13 +152,20 @@ export function ParentEmergencyContactEditPage() {
|
||||
`/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 & contacts overview →
|
||||
</Link>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +173,61 @@ export function ParentEmergencyContactEditPage() {
|
||||
/** Parity for CI student edit modal / edit_student flows (`edit_student_modal.php`). */
|
||||
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"
|
||||
@@ -52,10 +237,20 @@ export function ParentStudentEditPage() {
|
||||
`/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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -63,16 +258,39 @@ export function ParentStudentEditPage() {
|
||||
/** CI `parent/report-cards/view/(:num)` — report card PDF / viewer. */
|
||||
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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -86,10 +304,9 @@ export function ParentProgressAttachmentPage() {
|
||||
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>
|
||||
<a className="btn btn-outline-primary" href={`/api/v1/class-progress/attachments/${attachmentId}`}>
|
||||
Download attachment
|
||||
</a>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
@@ -103,10 +320,9 @@ export function ParentProgressAttachmentFilePage() {
|
||||
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>
|
||||
<a className="btn btn-outline-primary" href={`/api/v1/class-progress/${fileId}/attachment`}>
|
||||
Download legacy attachment
|
||||
</a>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
@@ -114,16 +330,42 @@ export function ParentProgressAttachmentFilePage() {
|
||||
/** CI `POST parent/report-cards/sign/(:num)` — acknowledgement / signature flow. */
|
||||
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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,212 @@
|
||||
import { createParentParityPage } from './createParentParityPage'
|
||||
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'
|
||||
|
||||
/** 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'],
|
||||
})
|
||||
function Notice({ message, type = 'success' }: { message: string | null; type?: 'success' | 'warning' }) {
|
||||
return message ? <CiFlashMessages messages={[{ type, message }]} /> : null
|
||||
}
|
||||
|
||||
/** 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'],
|
||||
})
|
||||
export function ParentEditAllStudentsPage() {
|
||||
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
/** CI `parent/add-student-form` — add student flow. */
|
||||
export const ParentAddStudentFormPage = createParentParityPage({
|
||||
title: 'Add student',
|
||||
ciViewFile: 'register_student.php',
|
||||
legacyCiRoutes: ['/parent/add-student-form'],
|
||||
})
|
||||
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
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 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'],
|
||||
})
|
||||
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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user