init project

This commit is contained in:
root
2026-04-23 00:21:02 -04:00
parent 23826c5528
commit 9191fd32f0
64 changed files with 10229 additions and 0 deletions
+68
View File
@@ -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>
)
}
+105
View File
@@ -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>
)
}
+54
View File
@@ -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>
)
}
+61
View File
@@ -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>
)
}
+44
View File
@@ -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&apos;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>
)
}
+78
View File
@@ -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>
)
}
+146
View File
@@ -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>
)
}
+610
View File
@@ -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>
)
}
+102
View File
@@ -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>
)
}
+142
View File
@@ -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>
)
}
+136
View File
@@ -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>
)
}
+73
View File
@@ -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>
)
}
+51
View File
@@ -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>
)
}
+437
View File
@@ -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
}
+129
View File
@@ -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 &amp; 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'],
})
+91
View File
@@ -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: [],
})