init project
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { SwaggerDocs } from './SwaggerDocs'
|
||||
|
||||
const STORAGE_KEY = 'alrahma_docs_jwt'
|
||||
|
||||
export function AdminDocs() {
|
||||
const [token, setToken] = useState('')
|
||||
const [saved, setSaved] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const t = localStorage.getItem(STORAGE_KEY)
|
||||
if (t) {
|
||||
setSaved(t)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const authHeader = saved ? `Bearer ${saved}` : null
|
||||
|
||||
const saveToken = useCallback(() => {
|
||||
const t = token.trim()
|
||||
if (!t) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
setSaved(null)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
setSaved(t)
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<div className="admin-docs-shell">
|
||||
<header className="docs-toolbar">
|
||||
<Link to="/docs">Public docs</Link>
|
||||
<label>
|
||||
JWT (admin){' '}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
placeholder={saved ? '•••••••• (saved)' : ''}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={saveToken}>
|
||||
Save token
|
||||
</button>
|
||||
{saved ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
setSaved(null)
|
||||
setToken('')
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
<SwaggerDocs bootstrapPath="/api/docs" authorization={authHeader} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchDashboardRoute } from '../api/session'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
|
||||
export function AppHome() {
|
||||
const { user } = useAuth()
|
||||
const [route, setRoute] = useState<string | null>(null)
|
||||
const [roles, setRoles] = useState<string[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetchDashboardRoute()
|
||||
const d = res.data?.dashboard
|
||||
if (!cancelled && d) {
|
||||
setRoute(d.route)
|
||||
setRoles(d.roles ?? [])
|
||||
setError(null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load dashboard.')
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const roleKeys = user?.roles ? Object.keys(user.roles).filter((k) => user.roles[k]) : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="h4 mb-3">Welcome</h2>
|
||||
<p className="lead mb-4">
|
||||
Signed in as <strong>{user?.name}</strong>.
|
||||
</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">Roles (from login)</h3>
|
||||
<ul className="mb-0">
|
||||
{roleKeys.length ? (
|
||||
roleKeys.map((r) => <li key={r}>{r}</li>)
|
||||
) : (
|
||||
<li className="text-muted">None reported</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="card border-0 shadow-sm h-100">
|
||||
<div className="card-body">
|
||||
<h3 className="h6 text-muted">Dashboard route (API)</h3>
|
||||
{error ? (
|
||||
<p className="text-danger small mb-0">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-1">
|
||||
<code>{route ?? '—'}</code>
|
||||
</p>
|
||||
<p className="small text-muted mb-0">
|
||||
Resolved roles: {roles.length ? roles.join(', ') : '—'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<h3 className="h6">Shortcuts</h3>
|
||||
<ul className="mb-0">
|
||||
<li>
|
||||
<Link to="/app/parent/home">Parent — Dashboard</Link> (parity with
|
||||
CodeIgniter <code>parent/parent_dashboard</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/app/parent/attendance">Parent — Attendance</Link> (parity
|
||||
with CodeIgniter <code>Views/parent/attendance.php</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/app/parent/home">Parent — All pages</Link> (navigation for
|
||||
every view under <code>Views/parent/</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/docs">Public API docs (Swagger)</Link>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { getCiMeta } from '../lib/ciRouteLookup'
|
||||
|
||||
/** Fallback for CI-style paths not yet ported to dedicated React pages. */
|
||||
export function CiPlaceholderPage() {
|
||||
const params = useParams()
|
||||
const star = params['*'] ?? ''
|
||||
const segments = star.split('/').filter(Boolean)
|
||||
const meta = getCiMeta(star)
|
||||
|
||||
const title = meta?.title ?? 'Section placeholder'
|
||||
const viewsPath = meta?.ciViewPath ?? (star ? `…/${star}` : '…')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="breadcrumb">
|
||||
<li className="breadcrumb-item">
|
||||
<Link to="/app/home">Home</Link>
|
||||
</li>
|
||||
{segments.map((s, i) => (
|
||||
<li
|
||||
key={`${s}-${i}`}
|
||||
className={`breadcrumb-item ${i === segments.length - 1 ? 'active' : ''}`}
|
||||
aria-current={i === segments.length - 1 ? 'page' : undefined}
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</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>.
|
||||
</p>
|
||||
<p className="small mb-3">
|
||||
SPA path: <code>/app/{star || '…'}</code>
|
||||
</p>
|
||||
<p className="small mb-0">
|
||||
<Link to="/app/browse">Browse all CI view routes</Link>
|
||||
{' · '}
|
||||
<Link to="/app/home">App home</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ciRouteMeta } from '../lib/ciRouteMeta.generated'
|
||||
import { ciRegistryKeyToAppPath } from '../lib/ciRouteLookup'
|
||||
|
||||
/** Browse every CodeIgniter view file mapped under `/app/…` (parity placeholders unless a dedicated route exists). */
|
||||
export function CiSitemapPage() {
|
||||
const grouped = useMemo(() => {
|
||||
const g = new Map<string, { key: string; title: string }[]>()
|
||||
for (const key of Object.keys(ciRouteMeta).sort()) {
|
||||
const top = key.includes('/') ? key.slice(0, key.indexOf('/')) : key
|
||||
const title = ciRouteMeta[key]?.title ?? key
|
||||
const list = g.get(top) ?? []
|
||||
list.push({ key, title })
|
||||
g.set(top, list)
|
||||
}
|
||||
return [...g.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="breadcrumb">
|
||||
<li className="breadcrumb-item">
|
||||
<Link to="/app/home">Home</Link>
|
||||
</li>
|
||||
<li className="breadcrumb-item active" aria-current="page">
|
||||
CI views
|
||||
</li>
|
||||
</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
|
||||
matching view path. Regenerate: <code>npm run gen:ci-meta</code>.
|
||||
</p>
|
||||
|
||||
<div className="row g-4">
|
||||
{grouped.map(([section, items]) => (
|
||||
<div key={section} className="col-12 col-md-6 col-xl-4">
|
||||
<div className="border rounded bg-white shadow-sm">
|
||||
<div className="px-3 py-2 border-bottom small fw-semibold text-muted">{section}</div>
|
||||
<ul className="list-unstyled mb-0 px-2 py-2" style={{ maxHeight: 280, overflow: 'auto' }}>
|
||||
{items.map(({ key, title }) => (
|
||||
<li key={key} className="small py-1">
|
||||
<Link to={ciRegistryKeyToAppPath(key)}>{title}</Link>
|
||||
<div className="text-muted" style={{ fontSize: '0.75rem' }}>
|
||||
<code>{key.replace(/\//g, ' / ')}</code>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
/** Mirrors CodeIgniter `Views/user/forgot_password.php` shell; reset flow remains on the Laravel site. */
|
||||
export function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="ci-auth-bg flex-grow-1 py-4">
|
||||
<div className="registration-form ci-parity container mt-5 mb-5 text-center">
|
||||
<div className="text-center mb-4">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/alrahma_logo.png"
|
||||
alt=""
|
||||
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||
onError={(ev) => {
|
||||
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h3 className="text-center text-success mb-4" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
Reset Your Password
|
||||
</h3>
|
||||
|
||||
<div className="mx-auto mb-4 text-muted" style={{ maxWidth: 560 }}>
|
||||
<p>Password reset requests are handled through the school's main website.</p>
|
||||
<p className="mb-0">
|
||||
If this SPA is only the API client, open the legacy site or contact the office for a
|
||||
reset link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2" style={{ maxWidth: 400, margin: '0 auto' }}>
|
||||
<Link className="btn btn-success item" to="/login">
|
||||
Back to Sign in
|
||||
</Link>
|
||||
<Link className="btn btn-secondary item" to="/register">
|
||||
Register now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
|
||||
/** Public landing — structure inspired by CodeIgniter `Views/landing_page/guest_dashboard.php` hero. */
|
||||
export function HomePage() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="flex-grow-1">
|
||||
<section className="hero-section position-relative overflow-hidden bg-white">
|
||||
<div className="welcome-message text-center py-5 px-3">
|
||||
<h1 className="display-5 fw-semibold text-success mb-2">
|
||||
Welcome to Al Rahma Sunday School
|
||||
</h1>
|
||||
<h2 className="h4 text-secondary mb-3">
|
||||
{isAuthenticated ? 'Welcome back' : 'Welcome, Guest!'}
|
||||
</h2>
|
||||
<p className="lead text-muted mx-auto mb-4" style={{ maxWidth: 640 }}>
|
||||
Use the portal to manage enrollment, attendance, grades, and family information — the
|
||||
same workflows as the CodeIgniter site, backed by the Laravel API.
|
||||
</p>
|
||||
<div className="d-flex flex-wrap justify-content-center gap-2">
|
||||
{isAuthenticated ? (
|
||||
<Link className="btn btn-success btn-lg px-4" to="/app/home">
|
||||
Go to dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link className="btn btn-success btn-lg px-4" to="/login">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link className="btn btn-outline-success btn-lg px-4" to="/register">
|
||||
Registration Form
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link className="btn btn-outline-secondary btn-lg" to="/docs">
|
||||
API documentation
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-5 border-top bg-light">
|
||||
<div className="container">
|
||||
<div className="row g-4 justify-content-center">
|
||||
<div className="col-md-4">
|
||||
<div className="card border-0 shadow-sm h-100">
|
||||
<div className="card-body">
|
||||
<h3 className="h6 text-success">
|
||||
<i className="bi bi-house-door me-2" aria-hidden />
|
||||
Families
|
||||
</h3>
|
||||
<p className="small text-muted mb-0">
|
||||
Parents sign in after registration is activated by email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card border-0 shadow-sm h-100">
|
||||
<div className="card-body">
|
||||
<h3 className="h6 text-success">
|
||||
<i className="bi bi-shield-lock me-2" aria-hidden />
|
||||
Secure portal
|
||||
</h3>
|
||||
<p className="small text-muted mb-0">
|
||||
Sessions use JWT from the Laravel API (<code>/api/v1/auth/login</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchDashboardRoute } from '../api/session'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
|
||||
|
||||
/** Matches CodeIgniter `Views/user/login.php` (JWT via `/api/v1/auth/login`). */
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const locState = location.state as { from?: string; registered?: boolean; message?: string } | null
|
||||
|
||||
const from = locState?.from ?? '/app/home'
|
||||
const flashSuccess =
|
||||
locState?.registered === true ? locState?.message ?? 'Registration submitted.' : null
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await login(email, password)
|
||||
if (result.ok === false) {
|
||||
setError(result.message)
|
||||
return
|
||||
}
|
||||
|
||||
let target = from.startsWith('/app') ? from : '/app/home'
|
||||
try {
|
||||
const dash = await fetchDashboardRoute()
|
||||
const route = dash.data?.dashboard?.route
|
||||
const mapped = spaPathFromCiUrl(route)
|
||||
if (mapped) target = mapped
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
|
||||
navigate(target, { replace: true })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ci-auth-bg flex-grow-1 py-4">
|
||||
<div className="registration-form ci-parity container mt-5 mb-5">
|
||||
<form id="loginForm" onSubmit={onSubmit}>
|
||||
<div className="text-center mb-4">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/alrahma_logo.png"
|
||||
alt=""
|
||||
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||
onError={(ev) => {
|
||||
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-center text-success mb-4"
|
||||
style={{ fontFamily: 'Arial, sans-serif' }}
|
||||
>
|
||||
Login to Your Account
|
||||
</h3>
|
||||
|
||||
{flashSuccess ? (
|
||||
<div className="alert alert-success text-center" role="alert">
|
||||
{flashSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label visually-hidden" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control item"
|
||||
id="email"
|
||||
placeholder="Enter your email"
|
||||
maxLength={50}
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 position-relative">
|
||||
<label className="form-label visually-hidden" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="form-control item pe-5"
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
maxLength={30}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link position-absolute top-50 end-0 translate-middle-y me-2 px-2 py-0 border-0"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
<i className={`bi ${showPassword ? 'bi-eye-slash' : 'bi-eye'}`} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger mt-3 text-center" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3 d-grid">
|
||||
<button type="submit" className="btn btn-success item" disabled={busy}>
|
||||
{busy ? 'Signing in…' : 'Login'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="forgot-password text-center mt-3">
|
||||
<Link to="/forgot-password">Forgot Password?</Link>
|
||||
</div>
|
||||
|
||||
<p className="text-center small mt-4 mb-0">
|
||||
Need an account? <Link to="/register">Register now</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
fetchRegisterCaptcha,
|
||||
fetchSchoolPolicyHtml,
|
||||
submitFullRegistration,
|
||||
type FullRegisterPayload,
|
||||
} from '../api/registerFull'
|
||||
import { NE_STATES } from '../data/neStates'
|
||||
|
||||
function formatUsPhone(input: string): string {
|
||||
const raw = input.replace(/\D/g, '').slice(0, 10)
|
||||
if (raw.length <= 3) return raw
|
||||
if (raw.length <= 6) return `${raw.slice(0, 3)}-${raw.slice(3)}`
|
||||
return `${raw.slice(0, 3)}-${raw.slice(3, 6)}-${raw.slice(6)}`
|
||||
}
|
||||
|
||||
/** Matches CodeIgniter `Views/user/register.php` + Laravel `POST /api/v1/auth/register` (captcha flow). */
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [captchaChallenge, setCaptchaChallenge] = useState('')
|
||||
const [captchaLoading, setCaptchaLoading] = useState(true)
|
||||
|
||||
const [firstname, setFirstname] = useState('')
|
||||
const [lastname, setLastname] = useState('')
|
||||
const [gender, setGender] = useState('')
|
||||
const [cellphone, setCellphone] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [confirmEmail, setConfirmEmail] = useState('')
|
||||
const [addressStreet, setAddressStreet] = useState('')
|
||||
const [apt, setApt] = useState('')
|
||||
const [city, setCity] = useState('')
|
||||
const [state, setState] = useState('')
|
||||
const [zip, setZip] = useState('')
|
||||
|
||||
const [isParent, setIsParent] = useState(false)
|
||||
const [noSecondParentInfo, setNoSecondParentInfo] = useState(false)
|
||||
const [secondFirstname, setSecondFirstname] = useState('')
|
||||
const [secondLastname, setSecondLastname] = useState('')
|
||||
const [secondGender, setSecondGender] = useState('')
|
||||
const [secondEmail, setSecondEmail] = useState('')
|
||||
const [secondCellphone, setSecondCellphone] = useState('')
|
||||
|
||||
const [acceptPolicy, setAcceptPolicy] = useState(false)
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('')
|
||||
|
||||
const [policyHtml, setPolicyHtml] = useState<string | null>(null)
|
||||
const [policyLoading, setPolicyLoading] = useState(false)
|
||||
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({})
|
||||
|
||||
async function loadCaptcha() {
|
||||
setCaptchaLoading(true)
|
||||
try {
|
||||
const { challenge } = await fetchRegisterCaptcha()
|
||||
setCaptchaChallenge(challenge)
|
||||
setCaptchaAnswer('')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not load CAPTCHA.')
|
||||
} finally {
|
||||
setCaptchaLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadCaptcha()
|
||||
}, [])
|
||||
|
||||
async function openPolicyModal() {
|
||||
if (!policyHtml) {
|
||||
setPolicyLoading(true)
|
||||
try {
|
||||
const html = await fetchSchoolPolicyHtml()
|
||||
setPolicyHtml(html)
|
||||
} catch {
|
||||
setPolicyHtml('<p>Unable to load policy.</p>')
|
||||
} finally {
|
||||
setPolicyLoading(false)
|
||||
}
|
||||
}
|
||||
const el = document.getElementById('policyModal')
|
||||
const B = (
|
||||
window as unknown as {
|
||||
bootstrap?: { Modal: { getOrCreateInstance: (n: HTMLElement) => { show: () => void } } }
|
||||
}
|
||||
).bootstrap
|
||||
if (el && B?.Modal) {
|
||||
B.Modal.getOrCreateInstance(el).show()
|
||||
}
|
||||
}
|
||||
|
||||
function fieldErr(key: string): string | undefined {
|
||||
return fieldErrors[key]?.[0]
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setFieldErrors({})
|
||||
|
||||
if (email.trim() !== confirmEmail.trim()) {
|
||||
setError('Email and confirm email must match.')
|
||||
return
|
||||
}
|
||||
if (!acceptPolicy) {
|
||||
setError('You must accept the school policies.')
|
||||
return
|
||||
}
|
||||
const zipOk = /^\d{5}$/.test(zip.trim())
|
||||
if (!zipOk) {
|
||||
setError('Zip must be exactly 5 digits.')
|
||||
return
|
||||
}
|
||||
const phoneDigits = cellphone.replace(/\D/g, '')
|
||||
if (phoneDigits.length !== 10) {
|
||||
setError('Please enter a valid 10-digit cell phone number.')
|
||||
return
|
||||
}
|
||||
|
||||
if (isParent && !noSecondParentInfo) {
|
||||
const sd = secondCellphone.replace(/\D/g, '')
|
||||
if (sd.length !== 10) {
|
||||
setError('Second parent phone must be a valid 10-digit number.')
|
||||
return
|
||||
}
|
||||
if (!secondFirstname.trim() || !secondLastname.trim() || !secondGender || !secondEmail.trim()) {
|
||||
setError('Please complete second parent / guardian information.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payload: FullRegisterPayload = {
|
||||
firstname,
|
||||
lastname,
|
||||
gender,
|
||||
cellphone,
|
||||
email,
|
||||
confirm_email: confirmEmail,
|
||||
address_street: addressStreet,
|
||||
apt,
|
||||
city,
|
||||
state,
|
||||
zip: zip.trim(),
|
||||
is_parent: isParent,
|
||||
no_second_parent_info: noSecondParentInfo,
|
||||
accept_school_policy: acceptPolicy,
|
||||
captcha: captchaAnswer,
|
||||
second_firstname: secondFirstname,
|
||||
second_lastname: secondLastname,
|
||||
second_gender: secondGender,
|
||||
second_email: secondEmail,
|
||||
second_cellphone: secondCellphone,
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await submitFullRegistration(payload)
|
||||
if (!result.ok) {
|
||||
setError(result.message)
|
||||
if ('errors' in result && result.errors) setFieldErrors(result.errors)
|
||||
void loadCaptcha()
|
||||
return
|
||||
}
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: {
|
||||
registered: true,
|
||||
message:
|
||||
'Registration submitted. Please check your email to activate your account when instructed.',
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showSecondSection = isParent
|
||||
const showSecondFields = isParent && !noSecondParentInfo
|
||||
|
||||
return (
|
||||
<div className="ci-auth-bg flex-grow-1 py-4">
|
||||
<div className="registration-form ci-parity container mt-3 mb-5">
|
||||
<form id="registrationForm" onSubmit={onSubmit}>
|
||||
<div className="text-center mb-4">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/alrahma_logo.png"
|
||||
alt="Al Rahma Sunday School"
|
||||
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||
onError={(ev) => {
|
||||
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-center text-success mb-3"
|
||||
style={{ fontFamily: 'Arial, sans-serif' }}
|
||||
>
|
||||
Registration Form
|
||||
</h3>
|
||||
|
||||
<div className="alert alert-info" role="alert">
|
||||
- This account is for parents/guardians to manage students. If you already registered
|
||||
in a prior year, please reuse your existing account.
|
||||
<br />- If you cannot access your old email, reset your password.
|
||||
</div>
|
||||
|
||||
<p className="text-center text-secondary mt-3 mb-2 small">
|
||||
(All fields with * are required)
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control item ${fieldErr('firstname') ? 'is-invalid' : ''}`}
|
||||
placeholder="First Name*"
|
||||
maxLength={50}
|
||||
required
|
||||
value={firstname}
|
||||
onChange={(e) => setFirstname(e.target.value)}
|
||||
/>
|
||||
{fieldErr('firstname') ? (
|
||||
<div className="text-danger small">{fieldErr('firstname')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control item ${fieldErr('lastname') ? 'is-invalid' : ''}`}
|
||||
placeholder="Last Name*"
|
||||
maxLength={50}
|
||||
required
|
||||
value={lastname}
|
||||
onChange={(e) => setLastname(e.target.value)}
|
||||
/>
|
||||
{fieldErr('lastname') ? (
|
||||
<div className="text-danger small">{fieldErr('lastname')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<select
|
||||
className={`form-select item ${fieldErr('gender') ? 'is-invalid' : ''}`}
|
||||
required
|
||||
value={gender}
|
||||
onChange={(e) => setGender(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select Gender*
|
||||
</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
{fieldErr('gender') ? (
|
||||
<div className="text-danger small">{fieldErr('gender')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="tel"
|
||||
className={`form-control item ${fieldErr('cellphone') ? 'is-invalid' : ''}`}
|
||||
placeholder="Phone - Cell*"
|
||||
maxLength={12}
|
||||
required
|
||||
title="10-digit US phone"
|
||||
value={cellphone}
|
||||
onChange={(e) => setCellphone(formatUsPhone(e.target.value))}
|
||||
/>
|
||||
{fieldErr('cellphone') ? (
|
||||
<div className="text-danger small">{fieldErr('cellphone')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="email"
|
||||
className={`form-control item ${fieldErr('email') ? 'is-invalid' : ''}`}
|
||||
placeholder="Email*"
|
||||
maxLength={50}
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
{fieldErr('email') ? (
|
||||
<div className="text-danger small">{fieldErr('email')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="email"
|
||||
className={`form-control item ${fieldErr('confirm_email') ? 'is-invalid' : ''}`}
|
||||
placeholder="Confirm Email*"
|
||||
maxLength={50}
|
||||
required
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{fieldErr('confirm_email') ? (
|
||||
<div className="text-danger small">{fieldErr('confirm_email')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control item ${fieldErr('address_street') ? 'is-invalid' : ''}`}
|
||||
placeholder="Address - Street*"
|
||||
maxLength={50}
|
||||
required
|
||||
value={addressStreet}
|
||||
onChange={(e) => setAddressStreet(e.target.value)}
|
||||
/>
|
||||
{fieldErr('address_street') ? (
|
||||
<div className="text-danger small">{fieldErr('address_street')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control item"
|
||||
placeholder="Apt"
|
||||
maxLength={15}
|
||||
value={apt}
|
||||
onChange={(e) => setApt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control item ${fieldErr('city') ? 'is-invalid' : ''}`}
|
||||
placeholder="City*"
|
||||
maxLength={30}
|
||||
required
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
{fieldErr('city') ? (
|
||||
<div className="text-danger small">{fieldErr('city')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<select
|
||||
className={`form-select item ${fieldErr('state') ? 'is-invalid' : ''}`}
|
||||
required
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select State*
|
||||
</option>
|
||||
{NE_STATES.map((s) => (
|
||||
<option key={s.abbr} value={s.abbr}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldErr('state') ? (
|
||||
<div className="text-danger small">{fieldErr('state')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control item ${fieldErr('zip') ? 'is-invalid' : ''}`}
|
||||
placeholder="Zip*"
|
||||
maxLength={5}
|
||||
inputMode="numeric"
|
||||
pattern="\d{5}"
|
||||
required
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value.replace(/\D/g, '').slice(0, 5))}
|
||||
/>
|
||||
{fieldErr('zip') ? (
|
||||
<div className="text-danger small">{fieldErr('zip')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="is_parent"
|
||||
checked={isParent}
|
||||
onChange={(e) => {
|
||||
setIsParent(e.target.checked)
|
||||
if (!e.target.checked) {
|
||||
setNoSecondParentInfo(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="is_parent">
|
||||
Check this box if you are a Parent/Guardian
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showSecondSection ? (
|
||||
<div
|
||||
className="mb-4 p-3 rounded"
|
||||
style={{ border: '1px solid #ccc' }}
|
||||
>
|
||||
<h6 className="mb-3">Second Parent/Guardian Information</h6>
|
||||
|
||||
<div className="form-check form-switch mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="no_second_parent_info"
|
||||
checked={noSecondParentInfo}
|
||||
onChange={(e) => setNoSecondParentInfo(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="no_second_parent_info">
|
||||
Check this box if no second parent info is available
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showSecondFields ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control item"
|
||||
placeholder="First Name*"
|
||||
maxLength={50}
|
||||
value={secondFirstname}
|
||||
onChange={(e) => setSecondFirstname(e.target.value)}
|
||||
/>
|
||||
{fieldErr('second_firstname') ? (
|
||||
<div className="text-danger small">{fieldErr('second_firstname')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control item"
|
||||
placeholder="Last Name*"
|
||||
maxLength={50}
|
||||
value={secondLastname}
|
||||
onChange={(e) => setSecondLastname(e.target.value)}
|
||||
/>
|
||||
{fieldErr('second_lastname') ? (
|
||||
<div className="text-danger small">{fieldErr('second_lastname')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<select
|
||||
className="form-select item"
|
||||
required
|
||||
value={secondGender}
|
||||
onChange={(e) => setSecondGender(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select Gender*
|
||||
</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="email"
|
||||
className="form-control item"
|
||||
placeholder="Email*"
|
||||
maxLength={50}
|
||||
value={secondEmail}
|
||||
onChange={(e) => setSecondEmail(e.target.value)}
|
||||
/>
|
||||
{fieldErr('second_email') ? (
|
||||
<div className="text-danger small">{fieldErr('second_email')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="tel"
|
||||
className="form-control item"
|
||||
placeholder="Phone - Cell*"
|
||||
maxLength={12}
|
||||
value={secondCellphone}
|
||||
onChange={(e) => setSecondCellphone(formatUsPhone(e.target.value))}
|
||||
/>
|
||||
{fieldErr('second_cellphone') ? (
|
||||
<div className="text-danger small">{fieldErr('second_cellphone')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="accept_school_policy"
|
||||
checked={acceptPolicy}
|
||||
required
|
||||
onChange={(e) => setAcceptPolicy(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="accept_school_policy">
|
||||
I have read and I accept all school policies{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0 small text-success align-baseline"
|
||||
onClick={() => void openPolicyModal()}
|
||||
>
|
||||
(Read school policies here)
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="text-center border p-2 bg-light item">
|
||||
{captchaLoading ? (
|
||||
<span className="text-muted">Loading CAPTCHA…</span>
|
||||
) : (
|
||||
<span className="user-select-all">{captchaChallenge}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm px-0"
|
||||
onClick={() => void loadCaptcha()}
|
||||
>
|
||||
Refresh CAPTCHA
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control item ${fieldErr('captcha') ? 'is-invalid' : ''}`}
|
||||
placeholder="Enter the text above*"
|
||||
maxLength={10}
|
||||
autoComplete="off"
|
||||
required
|
||||
value={captchaAnswer}
|
||||
onChange={(e) => setCaptchaAnswer(e.target.value)}
|
||||
/>
|
||||
{fieldErr('captcha') ? (
|
||||
<div className="text-danger small">{fieldErr('captcha')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 d-grid gap-2">
|
||||
<button type="submit" className="btn btn-success item" disabled={busy || captchaLoading}>
|
||||
{busy ? 'Submitting…' : 'Create Account'}
|
||||
</button>
|
||||
<Link to="/" className="btn btn-secondary item text-center">
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="modal fade"
|
||||
id="policyModal"
|
||||
tabIndex={-1}
|
||||
aria-labelledby="policyModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title text-success" id="policyModalLabel">
|
||||
School Policies
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{policyLoading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : (
|
||||
<div
|
||||
className="policy-html"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: policyHtml ?? '<p>Click “Read school policies” to load.</p>',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import SwaggerUI from 'swagger-ui-react'
|
||||
import 'swagger-ui-react/swagger-ui.css'
|
||||
import type { DocsBootstrap } from '../types/docsBootstrap'
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
|
||||
type Props = {
|
||||
bootstrapPath: string
|
||||
authorization?: string | null
|
||||
}
|
||||
|
||||
export function SwaggerDocs({ bootstrapPath, authorization }: Props) {
|
||||
const [bootstrap, setBootstrap] = useState<DocsBootstrap | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const headers: HeadersInit = { Accept: 'application/json' }
|
||||
if (authorization) {
|
||||
headers.Authorization = authorization
|
||||
}
|
||||
const res = await fetch(apiUrl(bootstrapPath), { headers })
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
typeof body?.message === 'string'
|
||||
? body.message
|
||||
: `HTTP ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!cancelled) {
|
||||
setBootstrap(body as DocsBootstrap)
|
||||
setError(null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setBootstrap(null)
|
||||
setError(e instanceof Error ? e.message : 'Failed to load docs config')
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bootstrapPath, authorization])
|
||||
|
||||
useEffect(() => {
|
||||
if (bootstrap?.title) {
|
||||
document.title = bootstrap.title
|
||||
}
|
||||
}, [bootstrap?.title])
|
||||
|
||||
const specUrl = useMemo(() => {
|
||||
if (!bootstrap?.openapi_url) {
|
||||
return ''
|
||||
}
|
||||
return apiUrl(bootstrap.openapi_url)
|
||||
}, [bootstrap])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="docs-error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!bootstrap || !specUrl) {
|
||||
return <div className="docs-loading">Loading documentation…</div>
|
||||
}
|
||||
|
||||
const hideAuthorize = !bootstrap.show_authorize_button
|
||||
|
||||
return (
|
||||
<div className={`docs-root variant-${bootstrap.variant}`}>
|
||||
{bootstrap.welcome_name ? (
|
||||
<div className="admin-banner">
|
||||
Welcome, <span>{bootstrap.welcome_name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{hideAuthorize ? <style>{`.swagger-ui .btn.authorize { display: none !important; }`}</style> : null}
|
||||
<SwaggerUI
|
||||
url={specUrl}
|
||||
docExpansion="none"
|
||||
defaultModelsExpandDepth={-1}
|
||||
deepLinking
|
||||
tryItOutEnabled={bootstrap.try_it_out_enabled}
|
||||
persistAuthorization={bootstrap.persist_authorization}
|
||||
requestInterceptor={(req: {
|
||||
headers: Record<string, string>
|
||||
}) => {
|
||||
if (authorization && !req.headers.Authorization) {
|
||||
req.headers.Authorization = authorization
|
||||
}
|
||||
return req
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
fetchParentAttendanceReportsList,
|
||||
fetchParentAuthorizedUsers,
|
||||
} from '../../api/session'
|
||||
import { ParentParityShell } from './ParentParityShell'
|
||||
|
||||
/** Secondary / authorized users — `GET /api/v1/parents/authorized-users`. */
|
||||
export function ParentAuthorizedUsersPage() {
|
||||
const [payload, setPayload] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchParentAuthorizedUsers()
|
||||
if (cancelled) return
|
||||
setPayload(JSON.stringify(res, null, 2))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Authorized users"
|
||||
ciViewFile="add_second_parent.php"
|
||||
legacyCiRoutes={['GET /api/v1/parents/authorized-users']}
|
||||
>
|
||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
) : (
|
||||
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 320, overflow: 'auto' }}>
|
||||
{payload}
|
||||
</pre>
|
||||
)}
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** Submitted reports — `GET /api/v1/parents/attendance-reports`. */
|
||||
export function ParentAttendanceReportsHistoryPage() {
|
||||
const [payload, setPayload] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchParentAttendanceReportsList()
|
||||
if (cancelled) return
|
||||
setPayload(JSON.stringify(res, null, 2))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="My attendance reports"
|
||||
ciViewFile="report_attendance.php"
|
||||
legacyCiRoutes={['GET /api/v1/parents/attendance-reports']}
|
||||
>
|
||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
) : (
|
||||
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||
{payload}
|
||||
</pre>
|
||||
)}
|
||||
<p className="small text-muted mt-2 mb-0">
|
||||
New submission:{' '}
|
||||
<Link to="/app/parent/report-attendance">Report attendance form</Link>
|
||||
</p>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { fetchParentAttendance } from '../../api/session'
|
||||
import type { ParentAttendanceRow } from '../../api/types'
|
||||
|
||||
export function ParentAttendancePage() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const schoolYearParam = params.get('school_year')
|
||||
|
||||
const [rows, setRows] = useState<ParentAttendanceRow[]>([])
|
||||
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
||||
const [selectedYear, setSelectedYear] = 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 data = await fetchParentAttendance(schoolYearParam)
|
||||
if (cancelled) return
|
||||
setRows(data.attendance ?? [])
|
||||
setSchoolYears(data.schoolYears ?? [])
|
||||
setSelectedYear(data.selectedYear ?? null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setRows([])
|
||||
setSchoolYears([])
|
||||
setSelectedYear(null)
|
||||
setError(e instanceof Error ? e.message : 'Unable to load attendance.')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYearParam])
|
||||
|
||||
function onYearChange(next: string) {
|
||||
const nextParams = new URLSearchParams(params)
|
||||
if (next) nextParams.set('school_year', next)
|
||||
else nextParams.delete('school_year')
|
||||
setParams(nextParams)
|
||||
}
|
||||
|
||||
const yearOptions =
|
||||
schoolYears.length > 0
|
||||
? schoolYears
|
||||
: selectedYear
|
||||
? [{ school_year: selectedYear }]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
Attendance Record
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="row g-2 align-items-end mb-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="col-auto">
|
||||
<label className="form-label mb-0" htmlFor="school_year">
|
||||
School year
|
||||
</label>
|
||||
<select
|
||||
id="school_year"
|
||||
className="form-select"
|
||||
value={schoolYearParam ?? selectedYear ?? ''}
|
||||
onChange={(e) => onYearChange(e.target.value)}
|
||||
disabled={loading || yearOptions.length === 0}
|
||||
>
|
||||
{yearOptions.length === 0 ? (
|
||||
<option value="">—</option>
|
||||
) : (
|
||||
yearOptions.map((y) => (
|
||||
<option key={y.school_year} value={y.school_year}>
|
||||
{y.school_year}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button type="button" className="btn btn-success" disabled={loading}>
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted">No attendance rows for this school year.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={`${row.date}-${row.firstname}-${row.lastname}-${i}`}>
|
||||
<td>{row.firstname}</td>
|
||||
<td>{row.lastname}</td>
|
||||
<td>{row.date}</td>
|
||||
<td>{row.status}</td>
|
||||
<td>{row.reason ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Navigate, useParams } from 'react-router-dom'
|
||||
|
||||
/** CI uses `parent/edit_student/(:num)` — canonical SPA uses `parent/students/:id/edit`. */
|
||||
export function RedirectParentLegacyStudentEdit() {
|
||||
const { studentId } = useParams<{ studentId: string }>()
|
||||
return <Navigate to={`/app/parent/students/${studentId ?? ''}/edit`} replace />
|
||||
}
|
||||
|
||||
/**
|
||||
* CI `parent/edit_emergency_contact/(:num)` and `parent/edit-emergency-contact/(:num)` →
|
||||
* SPA `parent/emergency-contact/:contactId`.
|
||||
*/
|
||||
export function RedirectParentLegacyEmergencyContactEdit() {
|
||||
const { contactId } = useParams<{ contactId: string }>()
|
||||
return <Navigate to={`/app/parent/emergency-contact/${contactId ?? ''}`} replace />
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchParentEmergencyContacts } from '../../api/session'
|
||||
import type { ParentEmergencyContactRow } from '../../api/types'
|
||||
import { ParentParityShell } from './ParentParityShell'
|
||||
|
||||
/** CI `parent/edit-emergency-contact` (no id) — GET /api/v1/parents/emergency-contacts */
|
||||
export function ParentEmergencyContactsPage() {
|
||||
const [rows, setRows] = useState<ParentEmergencyContactRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentEmergencyContacts()
|
||||
if (cancelled) return
|
||||
setRows(data.contacts ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load contacts.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Emergency contacts"
|
||||
ciViewFile="edit_emergency_contact.php"
|
||||
legacyCiRoutes={['/parent/edit-emergency-contact']}
|
||||
>
|
||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted small mb-0">No emergency contacts.</p>
|
||||
) : (
|
||||
<ul className="list-unstyled mb-0">
|
||||
{rows.map((c) => (
|
||||
<li key={c.id} className="border-bottom py-2">
|
||||
<Link to={`/app/parent/emergency-contact/${c.id}`}>
|
||||
<strong>{c.name || 'Contact'}</strong>
|
||||
</Link>
|
||||
<div className="small text-muted">
|
||||
{[c.cellphone, c.email].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchParentProfile } from '../../api/session'
|
||||
import type { ParentProfileRecord } from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthProvider'
|
||||
import { ParentSectionNav } from './ParentSectionNav'
|
||||
|
||||
function displayName(p: ParentProfileRecord | null): string {
|
||||
if (!p) return ''
|
||||
const fn = (p.firstname ?? '').trim()
|
||||
const ln = (p.lastname ?? '').trim()
|
||||
const full = `${fn} ${ln}`.trim()
|
||||
return full || (p.email ?? '').trim()
|
||||
}
|
||||
|
||||
export function ParentHomePage() {
|
||||
const { user } = useAuth()
|
||||
const [profile, setProfile] = useState<ParentProfileRecord | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentProfile()
|
||||
if (cancelled) return
|
||||
setProfile(data.profile ?? null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setProfile(null)
|
||||
setError(e instanceof Error ? e.message : 'Unable to load profile.')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const greeting = useMemo(() => {
|
||||
const fromApi = displayName(profile)
|
||||
if (fromApi) return fromApi
|
||||
return user?.name?.trim() || 'Parent'
|
||||
}, [profile, user?.name])
|
||||
|
||||
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 ? (
|
||||
<div className="alert alert-warning">
|
||||
{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.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ParentSectionNav } from './ParentSectionNav'
|
||||
|
||||
export type ParentParitySpec = {
|
||||
title: string
|
||||
/** File name under `app/Views/parent/` (e.g. `calendar.php`). */
|
||||
ciViewFile: string
|
||||
/** Example CodeIgniter URL paths (with leading slash). */
|
||||
legacyCiRoutes?: string[]
|
||||
}
|
||||
|
||||
type ShellProps = ParentParitySpec & {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function ParentParityShell({
|
||||
title,
|
||||
ciViewFile,
|
||||
legacyCiRoutes,
|
||||
children,
|
||||
}: ShellProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-center mb-3">
|
||||
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-muted small mb-0">
|
||||
CodeIgniter view:{' '}
|
||||
<code>
|
||||
app/Views/parent/{ciViewFile}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{legacyCiRoutes?.length ? (
|
||||
<p className="small text-muted mb-3">
|
||||
Legacy routes:{' '}
|
||||
{legacyCiRoutes.map((r, i) => (
|
||||
<span key={r}>
|
||||
{i > 0 ? ' · ' : null}
|
||||
<code>{r}</code>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="row g-3">
|
||||
<aside className="col-lg-3">
|
||||
<div className="border rounded bg-white shadow-sm p-2 small">
|
||||
<div className="fw-semibold text-muted px-2 py-1 border-bottom mb-2">Parent pages</div>
|
||||
<ParentSectionNav />
|
||||
</div>
|
||||
</aside>
|
||||
<div className="col-lg-9">
|
||||
<div className="alert alert-light border small mb-3">
|
||||
This SPA route mirrors the CI parent view. Implement using the matching handlers in{' '}
|
||||
<code>app_laravel/routes/api.php</code> (prefix <code>/api/v1/parents/...</code> where
|
||||
available).
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 small mb-0">
|
||||
<Link to="/app/parent/home">← Parent dashboard</Link>
|
||||
{' · '}
|
||||
<Link to="/app/home">App home</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
const LINKS: { to: string; label: string }[] = [
|
||||
{ to: '/app/parent/home', label: 'Dashboard' },
|
||||
{ to: '/app/parent/add-second-parent', label: 'Add second parent' },
|
||||
{ to: '/app/parent/assignments', label: 'Assignments' },
|
||||
{ to: '/app/parent/attendance', label: 'Attendance' },
|
||||
{ to: '/app/parent/calendar', label: 'Calendar' },
|
||||
{ to: '/app/parent/classes', label: 'Classes' },
|
||||
{ to: '/app/parent/progress', label: 'Class progress' },
|
||||
{ to: '/app/parent/edit-emergency-contact', label: 'Emergency contacts' },
|
||||
{ to: '/app/parent/edit-all-students', label: 'Edit all students' },
|
||||
{ to: '/app/parent/add-student-form', label: 'Add student form' },
|
||||
{ to: '/app/parent/add-emergency-form', label: 'Add emergency form' },
|
||||
{ to: '/app/parent/contact', label: 'Contact' },
|
||||
{ to: '/app/parent/enroll-classes', label: 'Enroll in classes' },
|
||||
{ to: '/app/parent/enroll/success', label: 'Enroll success' },
|
||||
{ to: '/app/parent/enroll/failure', label: 'Enroll failure' },
|
||||
{ to: '/app/parent/events', label: 'Events' },
|
||||
{ to: '/app/parent/invoice-payment', label: 'Invoice payment' },
|
||||
{ to: '/app/parent/no-kids-registered', label: 'No kids registered' },
|
||||
{ to: '/app/parent/message', label: 'Message' },
|
||||
{ to: '/app/parent/payment', label: 'Payments / invoices' },
|
||||
{ to: '/app/parent/payment/success', label: 'Payment success' },
|
||||
{ to: '/app/parent/register-student', label: 'Register student' },
|
||||
{ to: '/app/parent/report-attendance', label: 'Report attendance' },
|
||||
{ to: '/app/parent/attendance-reports', label: 'My attendance reports' },
|
||||
{ to: '/app/parent/authorized-users', label: 'Authorized users' },
|
||||
{ to: '/app/parent/update-profile', label: 'Update profile' },
|
||||
{ to: '/app/parent/report-cards', label: 'Report cards' },
|
||||
{ to: '/app/parent/scores', label: 'Scores' },
|
||||
{ to: '/app/parent/success', label: 'Success message' },
|
||||
{ to: '/app/parent/withdraw/success', label: 'Withdraw success' },
|
||||
]
|
||||
|
||||
export function ParentSectionNav() {
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`d-block px-2 py-1 rounded text-decoration-none small ${
|
||||
isActive ? 'bg-success text-white' : 'text-body'
|
||||
}`
|
||||
|
||||
return (
|
||||
<nav className="nav flex-column gap-1" aria-label="Parent section">
|
||||
{LINKS.map(({ to, label }) => (
|
||||
<NavLink key={to} to={to} className={linkClass} end={to === '/app/parent/home'}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ParentParityShell } from './ParentParityShell'
|
||||
import {
|
||||
fetchAttendanceReportForm,
|
||||
fetchParentEnrollments,
|
||||
fetchParentEventsOverview,
|
||||
fetchParentInvoices,
|
||||
fetchParentProfile,
|
||||
fetchParentRegistrationOverview,
|
||||
submitContactMessage,
|
||||
} from '../../api/session'
|
||||
import type { ParentEnrollmentStudent, ParentInvoiceRow } from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthProvider'
|
||||
|
||||
/** `Views/parent/contact.php` — POST /api/v1/contact */
|
||||
export function ParentContactPage() {
|
||||
const { user } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [subject, setSubject] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetchParentProfile()
|
||||
const p = res.profile
|
||||
if (cancelled || !p) return
|
||||
const fn = (p.firstname ?? '').trim()
|
||||
const ln = (p.lastname ?? '').trim()
|
||||
const full = `${fn} ${ln}`.trim()
|
||||
if (full) setName(full)
|
||||
if (p.email) setEmail(String(p.email))
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.name) return
|
||||
setName((n) => n || user.name)
|
||||
}, [user?.name])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const res = await submitContactMessage({ name, email, subject, message })
|
||||
if (res.status) {
|
||||
setStatus(res.message ?? 'Message sent.')
|
||||
setSubject('')
|
||||
setMessage('')
|
||||
} else {
|
||||
setError('Could not send message.')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Request failed.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Contact"
|
||||
ciViewFile="contact.php"
|
||||
legacyCiRoutes={['/parent/contact']}
|
||||
>
|
||||
{status ? <div className="alert alert-success small">{status}</div> : null}
|
||||
{error ? <div className="alert alert-danger small">{error}</div> : null}
|
||||
<form className="row g-3" onSubmit={onSubmit}>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="pc_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="pc_name"
|
||||
className="form-control"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="pc_email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="pc_email"
|
||||
type="email"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="pc_subject">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="pc_subject"
|
||||
className="form-control"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="pc_message">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="pc_message"
|
||||
className="form-control"
|
||||
rows={5}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-success" disabled={loading}>
|
||||
{loading ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
|
||||
export function ParentEnrollClassesPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
||||
const [year, setYear] = useState<string | null>(null)
|
||||
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentEnrollments(null)
|
||||
if (cancelled) return
|
||||
setSchoolYears(data.schoolYears ?? [])
|
||||
setYear(data.selectedYear ?? null)
|
||||
setStudents(data.students ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Enroll in classes"
|
||||
ciViewFile="enroll_classes.php"
|
||||
legacyCiRoutes={['/parent/enroll_classes']}
|
||||
>
|
||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="small text-muted mb-2">
|
||||
School year: <code>{year ?? '—'}</code>. Updates use{' '}
|
||||
<code>POST /api/v1/parents/enrollments</code> with enroll / withdraw payloads.
|
||||
</p>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Class</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={2} className="text-muted">
|
||||
No students for this overview.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
students.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
{s.firstname} {s.lastname}
|
||||
</td>
|
||||
<td>{s.class_section ?? '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{schoolYears.length > 1 ? (
|
||||
<p className="small mb-0">Additional years in response: {schoolYears.length}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
|
||||
export function ParentPaymentInvoicesPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentInvoices(null)
|
||||
if (cancelled) return
|
||||
setRows(data.invoices ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load invoices.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Payments & invoices"
|
||||
ciViewFile="payment_view.php"
|
||||
legacyCiRoutes={['/parent/payment']}
|
||||
>
|
||||
{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-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Invoice</th>
|
||||
<th>Balance</th>
|
||||
<th>Status</th>
|
||||
<th>Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-muted">
|
||||
No invoices.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((inv) => (
|
||||
<tr key={inv.id}>
|
||||
<td>{inv.id}</td>
|
||||
<td>{inv.invoice_number ?? '—'}</td>
|
||||
<td>
|
||||
{typeof inv.balance === 'number' ? inv.balance.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td>{inv.status ?? '—'}</td>
|
||||
<td>{inv.due_date ?? '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** `Views/parent/event_participation.php` — GET/POST /api/v1/parents/events … */
|
||||
export function ParentEventsPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [summary, setSummary] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentEventsOverview()
|
||||
if (cancelled) return
|
||||
setSummary(JSON.stringify(data, null, 2))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load events.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Event participation"
|
||||
ciViewFile="event_participation.php"
|
||||
legacyCiRoutes={['/parent/events']}
|
||||
>
|
||||
{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: 320, overflow: 'auto' }}>
|
||||
{summary}
|
||||
</pre>
|
||||
)}
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** `Views/parent/register_student.php` — GET /api/v1/parents/registration */
|
||||
export function ParentRegisterStudentPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [summary, setSummary] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentRegistrationOverview()
|
||||
if (cancelled) return
|
||||
setSummary(JSON.stringify(data, null, 2))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load registration.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Register student"
|
||||
ciViewFile="register_student.php"
|
||||
legacyCiRoutes={['/parent/register_student', '/parent/child_register']}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** `Views/parent/report_attendance.php` — form: GET /api/v1/parents/attendance-reports/form */
|
||||
export function ParentReportAttendancePage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [summary, setSummary] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchAttendanceReportForm()
|
||||
if (cancelled) return
|
||||
setSummary(JSON.stringify(data, null, 2))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled)
|
||||
setError(e instanceof Error ? e.message : 'Failed to load attendance report form.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Report attendance"
|
||||
ciViewFile="report_attendance.php"
|
||||
legacyCiRoutes={['/parent/report-attendance']}
|
||||
>
|
||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
) : (
|
||||
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||
{summary}
|
||||
</pre>
|
||||
)}
|
||||
<p className="small text-muted mt-2 mb-0">
|
||||
Submit payloads: <code>POST /api/v1/parents/attendance-reports</code>,{' '}
|
||||
<code>PATCH /api/v1/parents/attendance-reports/{'{id}'}</code>.
|
||||
</p>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { FC } from 'react'
|
||||
import { ParentParityShell, type ParentParitySpec } from './ParentParityShell'
|
||||
|
||||
export function createParentParityPage(spec: ParentParitySpec): FC {
|
||||
const Page: FC = () => <ParentParityShell {...spec} />
|
||||
Page.displayName = `ParentParity(${spec.ciViewFile.replace(/\W/g, '_')})`
|
||||
return Page
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { ParentParityShell } from './ParentParityShell'
|
||||
|
||||
export function ParentProgressViewPage() {
|
||||
const { progressId } = useParams<{ progressId: string }>()
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Class progress detail"
|
||||
ciViewFile="class_progress_view.php"
|
||||
legacyCiRoutes={[`/parent/progress/view/${progressId ?? ''}`]}
|
||||
>
|
||||
<p className="small mb-0">
|
||||
Progress id: <code>{progressId}</code>. Attachment downloads map to{' '}
|
||||
<code>/api/v1/class-progress/…</code> in Laravel when wired.
|
||||
</p>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function ParentEmergencyContactEditPage() {
|
||||
const { contactId } = useParams<{ contactId: string }>()
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Emergency contact"
|
||||
ciViewFile="edit_emergency_contact.php"
|
||||
legacyCiRoutes={[
|
||||
'/parent/edit-emergency-contact',
|
||||
`/parent/edit_emergency_contact/${contactId ?? ''}`,
|
||||
`/parent/edit-emergency-contact/${contactId ?? ''}`,
|
||||
]}
|
||||
>
|
||||
<p className="small mb-2">
|
||||
Contact id: <code>{contactId}</code>. Use{' '}
|
||||
<code>GET/PATCH /api/v1/parents/emergency-contacts</code> when implementing the form.
|
||||
</p>
|
||||
<Link to="/app/parent/register-student" className="small">
|
||||
Registration & contacts overview →
|
||||
</Link>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** Parity for CI student edit modal / edit_student flows (`edit_student_modal.php`). */
|
||||
export function ParentStudentEditPage() {
|
||||
const { studentId } = useParams<{ studentId: string }>()
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Edit student"
|
||||
ciViewFile="edit_student_modal.php"
|
||||
legacyCiRoutes={[
|
||||
`/parent/edit_student/${studentId ?? ''}`,
|
||||
`/parent/edit-student/${studentId ?? ''}`,
|
||||
]}
|
||||
>
|
||||
<p className="small mb-0">
|
||||
Student id: <code>{studentId}</code>. Wire to{' '}
|
||||
<code>PATCH /api/v1/parents/students/{'{studentId}'}</code>.
|
||||
</p>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** CI `parent/report-cards/view/(:num)` — report card PDF / viewer. */
|
||||
export function ParentReportCardsViewPage() {
|
||||
const { reportId } = useParams<{ reportId: string }>()
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Report card"
|
||||
ciViewFile="report_cards.php"
|
||||
legacyCiRoutes={[`/parent/report-cards/view/${reportId ?? ''}`]}
|
||||
>
|
||||
<p className="small mb-0">
|
||||
Report / student id: <code>{reportId}</code>. Laravel:{' '}
|
||||
<code>/api/v1/reports/report-cards/students/{'{studentId}'}</code> (with parent auth).
|
||||
</p>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** CI `parent/progress/attachment/(:num)` — file download route. */
|
||||
export function ParentProgressAttachmentPage() {
|
||||
const { attachmentId } = useParams<{ attachmentId: string }>()
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Class progress attachment"
|
||||
ciViewFile="class_progress_view.php"
|
||||
legacyCiRoutes={[`/parent/progress/attachment/${attachmentId ?? ''}`]}
|
||||
>
|
||||
<p className="small mb-0">
|
||||
Attachment id: <code>{attachmentId}</code>. Wire download to{' '}
|
||||
<code>/api/v1/class-progress/attachments/{'{attachmentId}'}</code>.
|
||||
</p>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** CI `parent/progress/attachment-file/(:num)` — legacy file stream. */
|
||||
export function ParentProgressAttachmentFilePage() {
|
||||
const { fileId } = useParams<{ fileId: string }>()
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Class progress attachment (legacy)"
|
||||
ciViewFile="class_progress_view.php"
|
||||
legacyCiRoutes={[`/parent/progress/attachment-file/${fileId ?? ''}`]}
|
||||
>
|
||||
<p className="small mb-0">
|
||||
File id: <code>{fileId}</code>. Legacy path uses{' '}
|
||||
<code>/api/v1/class-progress/{'{id}'}/attachment</code>.
|
||||
</p>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** CI `POST parent/report-cards/sign/(:num)` — acknowledgement / signature flow. */
|
||||
export function ParentReportCardsSignPage() {
|
||||
const { studentId } = useParams<{ studentId: string }>()
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Sign report card"
|
||||
ciViewFile="report_cards.php"
|
||||
legacyCiRoutes={[`POST /parent/report-cards/sign/${studentId ?? ''}`]}
|
||||
>
|
||||
<p className="small mb-0">
|
||||
Student id: <code>{studentId}</code>. Wire <code>POST</code> to Laravel report-cards
|
||||
acknowledgement endpoints under <code>/api/v1/reports/report-cards/…</code>.
|
||||
</p>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createParentParityPage } from './createParentParityPage'
|
||||
|
||||
/** CI `parent/edit-all-students` — bulk edit modal (composed from registration views). */
|
||||
export const ParentEditAllStudentsPage = createParentParityPage({
|
||||
title: 'Edit all students',
|
||||
ciViewFile: 'register_student.php',
|
||||
legacyCiRoutes: ['/parent/edit-all-students'],
|
||||
})
|
||||
|
||||
/** CI `parent/add-emergency-form` — partial / modal flow. */
|
||||
export const ParentAddEmergencyFormPage = createParentParityPage({
|
||||
title: 'Add emergency contact',
|
||||
ciViewFile: 'edit_emergency_contact.php',
|
||||
legacyCiRoutes: ['/parent/add-emergency-form'],
|
||||
})
|
||||
|
||||
/** CI `parent/add-student-form` — add student flow. */
|
||||
export const ParentAddStudentFormPage = createParentParityPage({
|
||||
title: 'Add student',
|
||||
ciViewFile: 'register_student.php',
|
||||
legacyCiRoutes: ['/parent/add-student-form'],
|
||||
})
|
||||
|
||||
/** POST `parent/updateProfile/(:num)` — profile updates (PATCH in Laravel API). */
|
||||
export const ParentUpdateProfileParityPage = createParentParityPage({
|
||||
title: 'Update profile',
|
||||
ciViewFile: 'register_student.php',
|
||||
legacyCiRoutes: ['POST /parent/updateProfile/:id'],
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createParentParityPage } from './createParentParityPage'
|
||||
|
||||
export const ParentAddSecondParentPage = createParentParityPage({
|
||||
title: 'Add second parent',
|
||||
ciViewFile: 'add_second_parent.php',
|
||||
legacyCiRoutes: ['/parent/add_second_parent'],
|
||||
})
|
||||
|
||||
export const ParentAssignmentsPage = createParentParityPage({
|
||||
title: 'Assignments',
|
||||
ciViewFile: 'assignments.php',
|
||||
legacyCiRoutes: [],
|
||||
})
|
||||
|
||||
export const ParentCalendarPage = createParentParityPage({
|
||||
title: 'School calendar',
|
||||
ciViewFile: 'calendar.php',
|
||||
legacyCiRoutes: ['/parent/calendar'],
|
||||
})
|
||||
|
||||
export const ParentClassesPage = createParentParityPage({
|
||||
title: 'Classes',
|
||||
ciViewFile: 'classes.php',
|
||||
legacyCiRoutes: [],
|
||||
})
|
||||
|
||||
export const ParentProgressListPage = createParentParityPage({
|
||||
title: 'Class progress',
|
||||
ciViewFile: 'class_progress_list.php',
|
||||
legacyCiRoutes: ['/parent/progress'],
|
||||
})
|
||||
|
||||
export const ParentInvoicePaymentPage = createParentParityPage({
|
||||
title: 'Invoice payment',
|
||||
ciViewFile: 'invoice_payment.php',
|
||||
legacyCiRoutes: ['/parent/invoice_payment'],
|
||||
})
|
||||
|
||||
export const ParentNoKidsRegisteredPage = createParentParityPage({
|
||||
title: 'No kids registered',
|
||||
ciViewFile: 'no_kids_registred.php',
|
||||
legacyCiRoutes: ['/parent/child_register', '/parent/register_student'],
|
||||
})
|
||||
|
||||
export const ParentMessagePage = createParentParityPage({
|
||||
title: 'Message',
|
||||
ciViewFile: 'parent_message.php',
|
||||
legacyCiRoutes: [],
|
||||
})
|
||||
|
||||
export const ParentEnrollFailurePage = createParentParityPage({
|
||||
title: 'Enrollment unsuccessful',
|
||||
ciViewFile: 'enroll_failure.php',
|
||||
legacyCiRoutes: ['/parent/enroll_failure'],
|
||||
})
|
||||
|
||||
export const ParentEnrollSuccessPage = createParentParityPage({
|
||||
title: 'Enrollment successful',
|
||||
ciViewFile: 'enroll_success.php',
|
||||
legacyCiRoutes: ['/parent/enroll_success'],
|
||||
})
|
||||
|
||||
export const ParentPaymentSuccessPage = createParentParityPage({
|
||||
title: 'Payment success',
|
||||
ciViewFile: 'payment_success.php',
|
||||
legacyCiRoutes: ['/parent/payment_success'],
|
||||
})
|
||||
|
||||
export const ParentReportCardsPage = createParentParityPage({
|
||||
title: 'Report cards',
|
||||
ciViewFile: 'report_cards.php',
|
||||
legacyCiRoutes: ['/parent/report-cards'],
|
||||
})
|
||||
|
||||
export const ParentScoresPage = createParentParityPage({
|
||||
title: 'Scores',
|
||||
ciViewFile: 'scores.php',
|
||||
legacyCiRoutes: ['/parent/scores'],
|
||||
})
|
||||
|
||||
export const ParentSuccessMessagePage = createParentParityPage({
|
||||
title: 'Success',
|
||||
ciViewFile: 'success_message.php',
|
||||
legacyCiRoutes: [],
|
||||
})
|
||||
|
||||
export const ParentWithdrawSuccessPage = createParentParityPage({
|
||||
title: 'Withdrawal successful',
|
||||
ciViewFile: 'withdraw_success.php',
|
||||
legacyCiRoutes: [],
|
||||
})
|
||||
Reference in New Issue
Block a user