add parent pages

This commit is contained in:
root
2026-04-23 02:24:05 -04:00
parent 9191fd32f0
commit 94700d4f0f
30 changed files with 8179 additions and 634 deletions
+1 -2
View File
@@ -94,8 +94,7 @@ export function AppHome() {
<li>
<Link to="/docs">Public API docs (Swagger)</Link>
</li>
<li>
<Link to="/app/browse">All CodeIgniter views (sitemap)</Link> open any{' '}
<li>
<code>app/Views</code> screen as an <code>/app/</code> parity route
</li>
</ul>
+2 -9
View File
@@ -9,8 +9,6 @@ export function CiPlaceholderPage() {
const meta = getCiMeta(star)
const title = meta?.title ?? 'Section placeholder'
const viewsPath = meta?.ciViewPath ?? (star ? `…/${star}` : '…')
return (
<div>
<nav aria-label="breadcrumb">
@@ -31,12 +29,7 @@ export function CiPlaceholderPage() {
</nav>
<h2 className="h4 mb-3">{title}</h2>
<p className="text-muted mb-2">
CodeIgniter view:{' '}
<code>
app/Views/{viewsPath}
</code>
</p>
<p className="text-muted small mb-3">
Implement this screen using the matching routes in <code>app_laravel/routes/api.php</code>{' '}
(JWT <code>Authorization: Bearer</code>). See also <Link to="/docs">API docs</Link>.
@@ -47,7 +40,7 @@ export function CiPlaceholderPage() {
<p className="small mb-0">
<Link to="/app/browse">Browse all CI view routes</Link>
{' · '}
<Link to="/app/home">App home</Link>
</p>
</div>
)
-1
View File
@@ -30,7 +30,6 @@ export function CiSitemapPage() {
</ol>
</nav>
<h2 className="h4 mb-2">CodeIgniter views SPA routes</h2>
<p className="text-muted small mb-4">
Generated from <code>app_codeigniter/app/Views/**/*.php</code>. Each link opens the parity
route under <code>/app/</code>; unimplemented screens use the section placeholder with the
+8
View File
@@ -31,8 +31,16 @@ export function LoginPage() {
setError(result.message)
return
}
if (result.requiresRoleSelection) {
navigate('/app/select-role', { replace: true, state: { from } })
return
}
const isParentLogin =
result.selectedRole?.toLowerCase() === 'parent' ||
result.roles.some((role) => role.toLowerCase() === 'parent')
let target = from.startsWith('/app') ? from : '/app/home'
if (isParentLogin && target === '/app/home') target = '/app/parent/home'
try {
const dash = await fetchDashboardRoute()
const route = dash.data?.dashboard?.route
+382
View File
@@ -0,0 +1,382 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import {
deleteNavBuilderItem,
fetchNavBuilderData,
reorderNavBuilderItems,
saveNavBuilderItem,
} from '../api/session'
import type { NavBuilderData, NavBuilderItem } from '../api/types'
import { CiFlashMessages, type CiFlashMessage } from '../components/CiPartials'
type NavFormState = {
id: string
menu_parent_id: string
label: string
url: string
icon_class: string
target: string
sort_order: string
is_enabled: boolean
roles: string[]
}
const blankForm: NavFormState = {
id: '',
menu_parent_id: '',
label: '',
url: '',
icon_class: '',
target: '',
sort_order: '0',
is_enabled: true,
roles: [],
}
export function NavBuilderPage() {
const [payload, setPayload] = useState<NavBuilderData>({
items: [],
roles: [],
parentOptions: [],
})
const [form, setForm] = useState<NavFormState>(blankForm)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [messages, setMessages] = useState<CiFlashMessage[]>([])
async function load() {
setLoading(true)
try {
const res = await fetchNavBuilderData()
setPayload(res.data?.data ?? { items: [], roles: [], parentOptions: [] })
setMessages([])
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Failed to load menu items.' }])
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const filteredParentOptions = useMemo(
() => payload.parentOptions.filter((option) => String(option.id) !== form.id),
[form.id, payload.parentOptions],
)
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
setMessages([])
try {
await saveNavBuilderItem({
id: form.id ? Number(form.id) : null,
menu_parent_id: form.menu_parent_id ? Number(form.menu_parent_id) : null,
label: form.label.trim(),
url: form.url.trim() || null,
icon_class: form.icon_class.trim() || null,
target: form.target.trim() || null,
sort_order: Number(form.sort_order) || 0,
is_enabled: form.is_enabled,
roles: form.roles.map(Number).filter((id) => id > 0),
})
setForm(blankForm)
setMessages([{ type: 'success', message: 'Menu saved.' }])
await load()
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to save menu item.' }])
} finally {
setSaving(false)
}
}
async function onDelete(item: NavBuilderItem) {
if (!window.confirm(`Delete "${item.label}"?`)) return
setMessages([])
try {
await deleteNavBuilderItem(item.id)
setMessages([{ type: 'success', message: 'Menu item deleted.' }])
if (form.id === String(item.id)) setForm(blankForm)
await load()
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to delete menu item.' }])
}
}
async function onSaveOrder() {
const orders = Object.fromEntries(
payload.items.map((item) => [String(item.id), Number(item.sort_order ?? item.order ?? 0)]),
)
setMessages([])
try {
await reorderNavBuilderItems(orders)
setMessages([{ type: 'success', message: 'Menu order saved.' }])
await load()
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to reorder menu items.' }])
}
}
function editItem(item: NavBuilderItem) {
setForm({
id: String(item.id),
menu_parent_id: item.menu_parent_id == null ? '' : String(item.menu_parent_id),
label: item.label ?? '',
url: item.url ?? '',
icon_class: item.icon_class ?? '',
target: item.target ?? '',
sort_order: String(item.sort_order ?? item.order ?? 0),
is_enabled: Boolean(item.is_enabled ?? item.enabled),
roles: item.roles?.ids?.map(String) ?? [],
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="container-fluid px-0 nav-builder-page">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h3 className="mb-1">Navbar Builder</h3>
</div>
<button className="btn btn-outline-secondary btn-sm" type="button" onClick={() => void load()}>
Refresh
</button>
</div>
<CiFlashMessages messages={messages} />
<div className="card mb-4 rounded-0 border-0" id="menuTableCard">
<div className="card-header d-flex justify-content-between align-items-center">
<span>Current Menu</span>
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void onSaveOrder()}>
Save order
</button>
</div>
<div className="card-body">
<div id="menuTableWrap" className="table-responsive">
<table id="menuTable" className="table table-striped table-hover align-middle">
<thead>
<tr>
<th>Actions</th>
<th>Label</th>
<th>URL</th>
<th>Parent</th>
<th>Roles</th>
<th>Order</th>
<th>Enabled</th>
<th>Depth</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="text-center text-muted">Loading menu...</td>
</tr>
) : payload.items.length === 0 ? (
<tr>
<td colSpan={8} className="text-center text-muted">No menu items found.</td>
</tr>
) : (
payload.items.map((item) => (
<tr key={item.id}>
<td className="text-nowrap">
<button className="btn btn-sm btn-outline-primary me-1" type="button" onClick={() => editItem(item)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" type="button" onClick={() => void onDelete(item)}>
Delete
</button>
</td>
<td>
<span className="text-muted">{'- '.repeat(item.depth ?? 0)}</span>
{item.label}
{item.icon_class ? <code className="ms-2 small">{item.icon_class}</code> : null}
</td>
<td>{item.url ? <code>{item.url}</code> : <span className="text-muted">-</span>}</td>
<td>{parentLabel(item, payload.items)}</td>
<td>
{(item.roles?.names ?? []).length > 0 ? (
item.roles?.names?.map((roleName) => (
<span key={roleName} className="badge badge-role me-1 mb-1">
{roleName}
</span>
))
) : (
<span className="text-muted">-</span>
)}
</td>
<td>
<input
className="form-control form-control-sm nav-builder-order"
type="number"
value={item.sort_order ?? item.order ?? 0}
onChange={(event) => {
const next = Number(event.target.value) || 0
setPayload((current) => ({
...current,
items: current.items.map((row) => (row.id === item.id ? { ...row, sort_order: next } : row)),
}))
}}
/>
</td>
<td>
{Boolean(item.is_enabled ?? item.enabled) ? (
<span className="badge bg-success">Yes</span>
) : (
<span className="badge bg-light text-dark">No</span>
)}
</td>
<td>{item.depth ?? 0}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="row">
<div className="col-lg-5">
<div className="card mb-3">
<div className="card-header">Add / Edit Item</div>
<div className="card-body">
<form onSubmit={onSubmit}>
<div className="mb-2">
<label className="form-label" htmlFor="menu_parent_id">Parent</label>
<select
name="menu_parent_id"
id="menu_parent_id"
className="form-select"
value={form.menu_parent_id}
onChange={(event) => setForm((current) => ({ ...current, menu_parent_id: event.target.value }))}
>
<option value="">- Top level -</option>
{filteredParentOptions.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="label">Label</label>
<input
className="form-control"
name="label"
id="label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
required
/>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="url">URL (relative or absolute)</label>
<input
className="form-control"
name="url"
id="url"
placeholder="e.g. administrator/student_profiles"
value={form.url}
onChange={(event) => setForm((current) => ({ ...current, url: event.target.value }))}
/>
</div>
<div className="row">
<div className="col-md-4 mb-2">
<label className="form-label" htmlFor="target">Target</label>
<select
name="target"
id="target"
className="form-select"
value={form.target}
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
>
<option value="">(default)</option>
<option value="_blank">_blank</option>
<option value="_self">_self</option>
</select>
</div>
<div className="col-md-4 mb-2">
<label className="form-label" htmlFor="sort_order">Order</label>
<input
className="form-control"
type="number"
name="sort_order"
id="sort_order"
value={form.sort_order}
onChange={(event) => setForm((current) => ({ ...current, sort_order: event.target.value }))}
/>
</div>
<div className="col-md-4 mb-2 d-flex align-items-end">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
name="is_enabled"
id="is_enabled"
checked={form.is_enabled}
onChange={(event) => setForm((current) => ({ ...current, is_enabled: event.target.checked }))}
/>
<label className="form-check-label" htmlFor="is_enabled">Enabled</label>
</div>
</div>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="icon_class">Icon class</label>
<input
className="form-control"
name="icon_class"
id="icon_class"
placeholder="e.g. fas fa-user-graduate"
value={form.icon_class}
onChange={(event) => setForm((current) => ({ ...current, icon_class: event.target.value }))}
/>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="roles">Roles (who can see)</label>
<select
name="roles"
id="roles"
className="form-select"
multiple
size={6}
value={form.roles}
onChange={(event) =>
setForm((current) => ({
...current,
roles: Array.from(event.target.selectedOptions).map((option) => option.value),
}))
}
>
{payload.roles.map((role) => (
<option key={role.id} value={role.id}>{role.name}</option>
))}
</select>
<small className="text-muted">Hold Ctrl/Cmd to select multiple.</small>
</div>
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>{' '}
<button className="btn btn-secondary" type="button" onClick={() => setForm(blankForm)}>
Reset
</button>
</form>
</div>
</div>
</div>
</div>
</div>
)
}
function parentLabel(item: NavBuilderItem, items: NavBuilderItem[]) {
if (item.parent_label) return item.parent_label
const parentId = item.menu_parent_id ?? item.parent_id
if (!parentId) return '-'
return items.find((row) => row.id === parentId)?.label ?? `#${parentId}`
}
+81
View File
@@ -0,0 +1,81 @@
import { useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { fetchDashboardRoute } from '../api/session'
import { useAuth } from '../auth/AuthProvider'
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
export function RoleSelectPage() {
const { user, roles, selectedRole, setSelectedRole } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [pickedRole, setPickedRole] = useState<string | null>(selectedRole)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
async function continueLogin() {
if (!pickedRole) {
setError('Please select a role to continue.')
return
}
setBusy(true)
setError(null)
setSelectedRole(pickedRole)
let target = from.startsWith('/app') ? from : '/app/home'
if (pickedRole.toLowerCase() === 'parent' && target === '/app/home') {
target = '/app/parent/home'
}
try {
const dash = await fetchDashboardRoute()
const route = dash.data?.dashboard?.route
const mapped = spaPathFromCiUrl(route)
if (mapped) target = mapped
} catch {
/* keep fallback target */
}
navigate(target, { replace: true })
}
return (
<div className="container py-4">
<div className="card border-0 shadow-sm mx-auto" style={{ maxWidth: 640 }}>
<div className="card-body p-4">
<h2 className="h4 mb-2">Select role</h2>
<p className="text-muted mb-3">
{user?.name ? `Signed in as ${user.name}.` : 'Signed in successfully.'} Choose a role
to continue.
</p>
<div className="list-group mb-3">
{roles.map((role) => (
<button
key={role}
type="button"
className={`list-group-item list-group-item-action text-start ${
pickedRole === role ? 'active' : ''
}`}
onClick={() => setPickedRole(role)}
>
{role}
</button>
))}
</div>
{error ? (
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
) : null}
<div className="d-grid">
<button type="button" className="btn btn-success" onClick={continueLogin} disabled={busy}>
{busy ? 'Continuing…' : 'Continue'}
</button>
</div>
</div>
</div>
</div>
)
}
+155 -59
View File
@@ -1,58 +1,127 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
fetchParentAttendanceReportsList,
fetchParentAuthorizedUsers,
} from '../../api/session'
import { ParentParityShell } from './ParentParityShell'
/** Secondary / authorized users — `GET /api/v1/parents/authorized-users`. */
export function ParentAuthorizedUsersPage() {
const [payload, setPayload] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const res = await fetchParentAuthorizedUsers()
if (cancelled) return
setPayload(JSON.stringify(res, null, 2))
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
return (
import { useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import {
fetchParentAttendanceReportsList,
fetchParentAuthorizedUsers,
inviteParentAuthorizedUser,
} from '../../api/session'
import type { ParentAttendanceReportsListResponse, ParentAuthorizedUserRow } from '../../api/types'
import { CiFlashMessages } from '../../components/CiPartials'
import { ParentParityShell } from './ParentParityShell'
/** Secondary / authorized users — `GET /api/v1/parents/authorized-users`. */
export function ParentAuthorizedUsersPage() {
const [rows, setRows] = useState<ParentAuthorizedUserRow[]>([])
const [email, setEmail] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
async function load(cancelled?: () => boolean) {
setLoading(true)
try {
const res = await fetchParentAuthorizedUsers()
if (cancelled?.()) return
setRows(res.data ?? [])
setError(null)
} catch (e) {
if (!cancelled?.()) setError(e instanceof Error ? e.message : 'Unable to load.')
} finally {
if (!cancelled?.()) setLoading(false)
}
}
useEffect(() => {
let cancelled = false
void load(() => cancelled)
return () => {
cancelled = true
}
}, [])
async function onInvite(e: FormEvent) {
e.preventDefault()
setStatus(null)
setError(null)
try {
const res = await inviteParentAuthorizedUser(email)
setStatus(res.message ?? 'Authorized user added.')
setEmail('')
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to add authorized user.')
}
}
return (
<ParentParityShell
title="Authorized users"
ciViewFile="add_second_parent.php"
legacyCiRoutes={['GET /api/v1/parents/authorized-users']}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 320, overflow: 'auto' }}>
{payload}
</pre>
)}
</ParentParityShell>
)
}
/** Submitted reports — `GET /api/v1/parents/attendance-reports`. */
export function ParentAttendanceReportsHistoryPage() {
const [payload, setPayload] = useState<string>('')
>
<CiFlashMessages
messages={[
...(status ? [{ type: 'success' as const, message: status }] : []),
...(error ? [{ type: 'warning' as const, message: error }] : []),
]}
/>
<form className="row g-2 align-items-end mb-3" onSubmit={onInvite}>
<div className="col-md-8">
<label className="form-label" htmlFor="authorized_user_email">
Email
</label>
<input
id="authorized_user_email"
type="email"
className="form-control"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="col-md-4">
<button type="submit" className="btn btn-success w-100">
Add authorized user
</button>
</div>
</form>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={3} className="text-muted">Loading</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={3} className="text-muted">No authorized users.</td>
</tr>
) : (
rows.map((row) => (
<tr key={row.id ?? row.email}>
<td>{row.id ?? '—'}</td>
<td>{row.email ?? '—'}</td>
<td>{row.created_at ?? '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</ParentParityShell>
)
}
/** Submitted reports — `GET /api/v1/parents/attendance-reports`. */
export function ParentAttendanceReportsHistoryPage() {
const [payload, setPayload] = useState<ParentAttendanceReportsListResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -61,9 +130,9 @@ export function ParentAttendanceReportsHistoryPage() {
;(async () => {
setLoading(true)
try {
const res = await fetchParentAttendanceReportsList()
if (cancelled) return
setPayload(JSON.stringify(res, null, 2))
const res = await fetchParentAttendanceReportsList()
if (cancelled) return
setPayload(res)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
@@ -82,14 +151,41 @@ export function ParentAttendanceReportsHistoryPage() {
ciViewFile="report_attendance.php"
legacyCiRoutes={['GET /api/v1/parents/attendance-reports']}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
<CiFlashMessages messages={error ? [{ type: 'warning', message: error }] : []} />
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 360, overflow: 'auto' }}>
{payload}
</pre>
)}
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Student</th>
<th>Date</th>
<th>Type</th>
<th>Status</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{(payload?.reports ?? []).length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">No attendance reports.</td>
</tr>
) : (
(payload?.reports ?? []).map((report) => (
<tr key={String(report.id ?? `${report.student_id}-${report.report_date}`)}>
<td>{`${report.firstname ?? ''} ${report.lastname ?? ''}`.trim() || '—'}</td>
<td>{String(report.report_date ?? '—')}</td>
<td>{String(report.type ?? '—')}</td>
<td>{String(report.status ?? '—')}</td>
<td>{String(report.reason ?? '—')}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<p className="small text-muted mt-2 mb-0">
New submission:{' '}
<Link to="/app/parent/report-attendance">Report attendance form</Link>
+3 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useSearchParams } from 'react-router-dom'
import { fetchParentAttendance } from '../../api/session'
import type { ParentAttendanceRow } from '../../api/types'
@@ -133,9 +133,9 @@ export function ParentAttendancePage() {
)}
<p className="small text-muted mt-4 mb-0">
<Link to="/app/parent/home">Parent dashboard</Link>
{' · '}
<Link to="/app/home">App home</Link>
</p>
</div>
)
+34 -66
View File
@@ -1,9 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchParentProfile } from '../../api/session'
import type { ParentProfileRecord } from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
import { ParentSectionNav } from './ParentSectionNav'
import {
fetchParentAttendance,
fetchParentEnrollments,
fetchParentInvoices,
fetchParentProfile,
} from '../../api/session'
import type { ParentAttendanceRow, ParentEnrollmentStudent, ParentInvoiceRow, ParentProfileRecord } from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
function displayName(p: ParentProfileRecord | null): string {
if (!p) return ''
@@ -16,6 +20,9 @@ function displayName(p: ParentProfileRecord | null): string {
export function ParentHomePage() {
const { user } = useAuth()
const [profile, setProfile] = useState<ParentProfileRecord | null>(null)
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
const [attendance, setAttendance] = useState<ParentAttendanceRow[]>([])
const [invoices, setInvoices] = useState<ParentInvoiceRow[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -24,9 +31,17 @@ export function ParentHomePage() {
;(async () => {
setLoading(true)
try {
const data = await fetchParentProfile()
const [data, enrollmentData, attendanceData, invoiceData] = await Promise.all([
fetchParentProfile(),
fetchParentEnrollments(null),
fetchParentAttendance(null),
fetchParentInvoices(null),
])
if (cancelled) return
setProfile(data.profile ?? null)
setStudents(enrollmentData.students ?? [])
setAttendance(attendanceData.attendance ?? [])
setInvoices(invoiceData.invoices ?? [])
setError(null)
} catch (e) {
if (!cancelled) {
@@ -48,15 +63,15 @@ export function ParentHomePage() {
return user?.name?.trim() || 'Parent'
}, [profile, user?.name])
const absentCount = attendance.filter((row) => row.status.toLowerCase() === 'absent').length
const balance = invoices.reduce((sum, invoice) => sum + (Number(invoice.balance) || 0), 0)
return (
<div>
<div className="text-center mb-4">
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
Parent dashboard
</h2>
<p className="text-muted small mb-0">
Parity with CodeIgniter <code>Views/parent/parent_dashboard</code>
</p>
</div>
{error ? (
@@ -64,7 +79,7 @@ export function ParentHomePage() {
{error}{' '}
<span className="text-muted">
Parent-only APIs require a parent account. Use the general{' '}
<Link to="/app/home">app home</Link> if you landed here by mistake.
if you landed here by mistake.
</span>
</div>
) : null}
@@ -72,65 +87,18 @@ export function ParentHomePage() {
{loading ? (
<p className="text-muted">Loading</p>
) : (
<div className="row g-3 mb-4">
<div className="col-md-6">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<h3 className="h6 text-muted">Account</h3>
<p className="mb-1">
<strong>{greeting}</strong>
</p>
{profile?.email ? (
<p className="small text-muted mb-0">{profile.email}</p>
) : null}
{profile?.cellphone ? (
<p className="small text-muted mb-0">{profile.cellphone}</p>
) : null}
</div>
</div>
</div>
<div className="col-md-6">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<h3 className="h6 text-muted">Address</h3>
{profile?.address_street || profile?.city || profile?.state ? (
<p className="small mb-0">
{[profile.address_street, profile.city, profile.state, profile.zip]
.filter(Boolean)
.join(', ')}
</p>
) : (
<p className="text-muted small mb-0">No address on file.</p>
)}
</div>
</div>
</div>
<div className="parent-dashboard-circles row g-4 justify-content-center text-center mb-4">
<div className="col-auto"><Link to="/parent_guide.pdf" className="parent-circle bg-info text-white"><strong>Your Quick Guide</strong></Link></div>
<div className="col-auto"><div className="parent-circle bg-primary text-white"><strong>{greeting}</strong><small>{profile?.email ?? 'Parent account'}</small></div></div>
<div className="col-auto"><Link to="/app/parent/register-student" className="parent-circle bg-success text-white"><strong>Students</strong><small>{students.length} total</small></Link></div>
<div className="col-auto"><Link to="/app/parent/attendance" className="parent-circle bg-danger text-white"><strong>Absences</strong><small>{absentCount} absent</small></Link></div>
<div className="col-auto"><Link to="/app/parent/report-attendance" className="parent-circle bg-secondary text-white"><strong>Report Absence</strong></Link></div>
<div className="col-auto"><Link to="/app/parent/scores" className="parent-circle bg-warning text-dark"><strong>Scores</strong><small>View grades</small></Link></div>
<div className="col-auto"><Link to="/app/parent/report-cards" className="parent-circle bg-dark text-white"><strong>Report Cards</strong></Link></div>
<div className="col-auto"><Link to="/app/parent/enroll-classes" className="parent-circle bg-info text-white"><strong>Enrollment</strong><small>{students.filter((s) => s.enrollment_status).length} records</small></Link></div>
<div className="col-auto"><Link to="/app/parent/payment" className="parent-circle bg-secondary text-white"><strong>Payment</strong><small>${balance.toFixed(2)}</small></Link></div>
</div>
)}
<div className="row g-3">
<div className="col-md-6">
<div className="border rounded p-3 bg-white h-100">
<h3 className="h6 mb-3">Shortcuts</h3>
<ul className="mb-0">
<li>
<Link to="/app/parent/attendance">Attendance record</Link>
</li>
<li>
<Link to="/app/home">App home</Link>
</li>
</ul>
</div>
</div>
<div className="col-md-6">
<div className="border rounded p-3 bg-white h-100">
<h3 className="h6 mb-2">All parent pages (CI parity)</h3>
<div className="small" style={{ maxHeight: 220, overflow: 'auto' }}>
<ParentSectionNav />
</div>
</div>
</div>
</div>
</div>
)
}
+10 -47
View File
@@ -1,8 +1,6 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ParentSectionNav } from './ParentSectionNav'
export type ParentParitySpec = {
import type { ReactNode } from 'react'
export type ParentParitySpec = {
title: string
/** File name under `app/Views/parent/` (e.g. `calendar.php`). */
ciViewFile: string
@@ -14,60 +12,25 @@ type ShellProps = ParentParitySpec & {
children?: ReactNode
}
export function ParentParityShell({
title,
ciViewFile,
legacyCiRoutes,
children,
}: ShellProps) {
export function ParentParityShell({
title,
children,
}: ShellProps) {
return (
<div>
<div className="text-center mb-3">
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
{title}
</h2>
<p className="text-muted small mb-0">
CodeIgniter view:{' '}
<code>
app/Views/parent/{ciViewFile}
</code>
</p>
</div>
{legacyCiRoutes?.length ? (
<p className="small text-muted mb-3">
Legacy routes:{' '}
{legacyCiRoutes.map((r, i) => (
<span key={r}>
{i > 0 ? ' · ' : null}
<code>{r}</code>
</span>
))}
</p>
) : null}
<div className="row g-3">
<aside className="col-lg-3">
<div className="border rounded bg-white shadow-sm p-2 small">
<div className="fw-semibold text-muted px-2 py-1 border-bottom mb-2">Parent pages</div>
<ParentSectionNav />
</div>
</aside>
<div className="col-lg-9">
<div className="alert alert-light border small mb-3">
This SPA route mirrors the CI parent view. Implement using the matching handlers in{' '}
<code>app_laravel/routes/api.php</code> (prefix <code>/api/v1/parents/...</code> where
available).
</div>
{children}
</div>
</div>
<p className="mt-3 small mb-0">
<Link to="/app/parent/home"> Parent dashboard</Link>
{' · '}
<Link to="/app/home">App home</Link>
</p>
</div>
</div>
)
}
+159 -50
View File
@@ -4,12 +4,19 @@ import {
fetchAttendanceReportForm,
fetchParentEnrollments,
fetchParentEventsOverview,
fetchParentInvoices,
fetchParentProfile,
fetchParentRegistrationOverview,
submitContactMessage,
} from '../../api/session'
import type { ParentEnrollmentStudent, ParentInvoiceRow } from '../../api/types'
fetchParentInvoices,
fetchParentProfile,
fetchParentRegistrationOverview,
submitParentAttendanceReport,
submitContactMessage,
} from '../../api/session'
import type {
ParentAttendanceReportFormResponse,
ParentEnrollmentStudent,
ParentEventsOverviewResponse,
ParentInvoiceRow,
ParentRegistrationOverviewResponse,
} from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
/** `Views/parent/contact.php` — POST /api/v1/contact */
@@ -300,19 +307,19 @@ export function ParentPaymentInvoicesPage() {
}
/** `Views/parent/event_participation.php` — GET/POST /api/v1/parents/events … */
export function ParentEventsPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [summary, setSummary] = useState<string>('')
export function ParentEventsPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [data, setData] = useState<ParentEventsOverviewResponse | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchParentEventsOverview()
if (cancelled) return
setSummary(JSON.stringify(data, null, 2))
const data = await fetchParentEventsOverview()
if (cancelled) return
setData(data)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load events.')
@@ -335,28 +342,43 @@ export function ParentEventsPage() {
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 320, overflow: 'auto' }}>
{summary}
</pre>
)}
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr><th>Event</th><th>Date</th><th>Description</th></tr>
</thead>
<tbody>
{(data?.activeEvents ?? []).length === 0 ? (
<tr><td colSpan={3} className="text-muted">No active events.</td></tr>
) : (data?.activeEvents ?? []).map((event) => (
<tr key={String(event.id ?? event.title ?? event.event_name)}>
<td>{event.title ?? event.event_name ?? '—'}</td>
<td>{event.event_date ?? event.start_date ?? '—'}</td>
<td>{event.description ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ParentParityShell>
)
}
/** `Views/parent/register_student.php` — GET /api/v1/parents/registration */
export function ParentRegisterStudentPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [summary, setSummary] = useState<string>('')
export function ParentRegisterStudentPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [data, setData] = useState<ParentRegistrationOverviewResponse | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchParentRegistrationOverview()
if (cancelled) return
setSummary(JSON.stringify(data, null, 2))
const data = await fetchParentRegistrationOverview()
if (cancelled) return
setData(data)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load registration.')
@@ -379,28 +401,68 @@ export function ParentRegisterStudentPage() {
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 360, overflow: 'auto' }}>
{summary}
</pre>
)}
<>
<div className="d-flex flex-wrap gap-2 mb-3">
<a className="btn btn-success btn-sm" href="/app/parent/add-student-form">Add student</a>
<a className="btn btn-outline-success btn-sm" href="/app/parent/add-emergency-form">Add emergency contact</a>
</div>
<h3 className="h6">Students</h3>
<div className="table-responsive mb-3">
<table className="table table-sm table-striped">
<thead><tr><th>Name</th><th>Grade</th><th>Class</th><th>Status</th></tr></thead>
<tbody>
{(data?.existingKids ?? []).length === 0 ? (
<tr><td colSpan={4} className="text-muted">No students registered.</td></tr>
) : (data?.existingKids ?? []).map((student) => (
<tr key={student.id}>
<td>{student.firstname} {student.lastname}</td>
<td>{student.registration_grade ?? '—'}</td>
<td>{student.class_section ?? '—'}</td>
<td>{student.admission_status ?? student.enrollment_status ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="h6">Emergency contacts</h3>
<div className="table-responsive">
<table className="table table-sm table-striped">
<thead><tr><th>Name</th><th>Phone</th><th>Email</th><th>Relation</th></tr></thead>
<tbody>
{(data?.emergencies ?? []).length === 0 ? (
<tr><td colSpan={4} className="text-muted">No emergency contacts.</td></tr>
) : (data?.emergencies ?? []).map((contact) => (
<tr key={contact.id}>
<td>{contact.name}</td>
<td>{contact.cellphone}</td>
<td>{contact.email ?? '—'}</td>
<td>{contact.relation ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</ParentParityShell>
)
}
/** `Views/parent/report_attendance.php` — form: GET /api/v1/parents/attendance-reports/form */
export function ParentReportAttendancePage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [summary, setSummary] = useState<string>('')
export function ParentReportAttendancePage() {
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [data, setData] = useState<ParentAttendanceReportFormResponse | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchAttendanceReportForm()
if (cancelled) return
setSummary(JSON.stringify(data, null, 2))
const data = await fetchAttendanceReportForm()
if (cancelled) return
setData(data)
setError(null)
} catch (e) {
if (!cancelled)
@@ -420,18 +482,65 @@ export function ParentReportAttendancePage() {
ciViewFile="report_attendance.php"
legacyCiRoutes={['/parent/report-attendance']}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 360, overflow: 'auto' }}>
{summary}
</pre>
)}
<p className="small text-muted mt-2 mb-0">
Submit payloads: <code>POST /api/v1/parents/attendance-reports</code>,{' '}
<code>PATCH /api/v1/parents/attendance-reports/{'{id}'}</code>.
</p>
</ParentParityShell>
)
}
{status ? <div className="alert alert-success small">{status}</div> : null}
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<form
className="row g-3"
onSubmit={async (e) => {
e.preventDefault()
setStatus(null)
setError(null)
const form = new FormData(e.currentTarget)
try {
const res = await submitParentAttendanceReport({
student_ids: [Number(form.get('student_id'))],
date: String(form.get('date') ?? ''),
type: String(form.get('type') ?? ''),
arrival_time: String(form.get('arrival_time') ?? ''),
dismiss_time: String(form.get('dismiss_time') ?? ''),
reason: String(form.get('reason') ?? ''),
})
setStatus(`Report submitted. Inserted: ${res.inserted ?? 0}`)
e.currentTarget.reset()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit report.')
}
}}
>
<div className="col-md-6">
<label className="form-label">Student</label>
<select name="student_id" className="form-control" required>
<option value="">Select student</option>
{Array.isArray(data?.students)
? data.students.map((student: any) => (
<option key={student.id} value={student.id}>
{student.firstname} {student.lastname}
</option>
))
: null}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Date</label>
<input name="date" type="date" className="form-control" defaultValue={String(data?.defaultDate ?? '')} required />
</div>
<div className="col-md-3">
<label className="form-label">Type</label>
<select name="type" className="form-control" required>
<option value="absent">Absent</option>
<option value="late">Late</option>
<option value="early_dismissal">Early dismissal</option>
</select>
</div>
<div className="col-md-6"><label className="form-label">Arrival time</label><input name="arrival_time" type="time" className="form-control" /></div>
<div className="col-md-6"><label className="form-label">Dismiss time</label><input name="dismiss_time" type="time" className="form-control" /></div>
<div className="col-12"><label className="form-label">Reason</label><textarea name="reason" className="form-control" rows={3} /></div>
<div className="col-12"><button className="btn btn-success" type="submit">Submit report</button></div>
</form>
)}
</ParentParityShell>
)
}
+321 -79
View File
@@ -1,25 +1,148 @@
import { Link, useParams } from 'react-router-dom'
import { ParentParityShell } from './ParentParityShell'
export function ParentProgressViewPage() {
const { progressId } = useParams<{ progressId: string }>()
return (
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
fetchClassProgressDetail,
fetchParentEmergencyContacts,
fetchParentRegistrationOverview,
fetchReportCardAcknowledgement,
fetchReportCardPdf,
updateParentEmergencyContact,
updateParentStudent,
} from '../../api/session'
import type {
ClassProgressReportRow,
ParentEmergencyContactRow,
ParentEnrollmentStudent,
ReportCardAcknowledgementRow,
} from '../../api/types'
import { CiEmergencyContactFormPartial, CiFlashMessages, CiStudentFormPartial } from '../../components/CiPartials'
import { ParentParityShell } from './ParentParityShell'
function fieldValue(value: unknown) {
return value == null || value === '' ? '—' : String(value)
}
export function ParentProgressViewPage() {
const { progressId } = useParams<{ progressId: string }>()
const id = Number(progressId)
const [reports, setReports] = useState<ClassProgressReportRow[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
let cancelled = false
;(async () => {
setLoading(true)
try {
const res = await fetchClassProgressDetail(id)
if (cancelled) return
const weekly = Object.values(res.data?.weekly_reports ?? {})
const fallback = res.data?.report ? [res.data.report] : []
setReports(weekly.length > 0 ? weekly : fallback)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load progress.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [id])
return (
<ParentParityShell
title="Class progress detail"
ciViewFile="class_progress_view.php"
legacyCiRoutes={[`/parent/progress/view/${progressId ?? ''}`]}
>
<p className="small mb-0">
Progress id: <code>{progressId}</code>. Attachment downloads map to{' '}
<code>/api/v1/class-progress/</code> in Laravel when wired.
</p>
</ParentParityShell>
)
}
export function ParentEmergencyContactEditPage() {
const { contactId } = useParams<{ contactId: string }>()
return (
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<div className="table-responsive">
<table className="table table-sm table-bordered align-middle">
<thead>
<tr>
<th>Subject</th>
<th>Unit</th>
<th>Covered</th>
<th>Homework</th>
<th>Status</th>
<th>Support</th>
</tr>
</thead>
<tbody>
{reports.length === 0 ? (
<tr><td colSpan={6} className="text-muted">No progress detail found.</td></tr>
) : reports.map((report) => (
<tr key={report.id}>
<td>{fieldValue(report.subject)}</td>
<td>{fieldValue(report.unit_title)}</td>
<td>{fieldValue(report.covered)}</td>
<td>{fieldValue(report.homework)}</td>
<td>{fieldValue(report.status_label ?? report.status)}</td>
<td>{fieldValue(report.support_needed)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ParentParityShell>
)
}
export function ParentEmergencyContactEditPage() {
const { contactId } = useParams<{ contactId: string }>()
const id = Number(contactId)
const [contact, setContact] = useState<ParentEmergencyContactRow | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const res = await fetchParentEmergencyContacts()
if (cancelled) return
setContact((res.contacts ?? []).find((row) => row.id === id) ?? null)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load contact.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [id])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!contact?.id) return
setStatus(null)
setError(null)
const form = new FormData(e.currentTarget)
try {
await updateParentEmergencyContact(contact.id, {
name: String(form.get('name') ?? ''),
cellphone: String(form.get('cellphone') ?? ''),
email: String(form.get('email') ?? ''),
relation: String(form.get('relation') ?? ''),
})
setStatus('Emergency contact updated.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update contact.')
}
}
return (
<ParentParityShell
title="Emergency contact"
ciViewFile="edit_emergency_contact.php"
@@ -28,22 +151,84 @@ export function ParentEmergencyContactEditPage() {
`/parent/edit_emergency_contact/${contactId ?? ''}`,
`/parent/edit-emergency-contact/${contactId ?? ''}`,
]}
>
<p className="small mb-2">
Contact id: <code>{contactId}</code>. Use{' '}
<code>GET/PATCH /api/v1/parents/emergency-contacts</code> when implementing the form.
</p>
<Link to="/app/parent/register-student" className="small">
Registration &amp; contacts overview
</Link>
</ParentParityShell>
)
}
>
<CiFlashMessages
messages={[
...(status ? [{ type: 'success' as const, message: status }] : []),
...(error ? [{ type: 'warning' as const, message: error }] : []),
]}
/>
{loading ? <p className="text-muted small">Loading</p> : null}
{!loading && !contact ? <div className="alert alert-info small">Contact not found.</div> : null}
{contact ? (
<form onSubmit={onSubmit}>
<CiEmergencyContactFormPartial defaultValues={contact} />
<button className="btn btn-success" type="submit">Save contact</button>
</form>
) : null}
</ParentParityShell>
)
}
/** Parity for CI student edit modal / edit_student flows (`edit_student_modal.php`). */
export function ParentStudentEditPage() {
const { studentId } = useParams<{ studentId: string }>()
return (
export function ParentStudentEditPage() {
const { studentId } = useParams<{ studentId: string }>()
const id = Number(studentId)
const [student, setStudent] = useState<ParentEnrollmentStudent | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const res = await fetchParentRegistrationOverview()
if (cancelled) return
setStudent((res.existingKids ?? []).find((row) => row.id === id) ?? null)
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load student.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [id])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!student?.id) return
const form = new FormData(e.currentTarget)
setStatus(null)
setError(null)
try {
const allergies = form.getAll('allergies').map(String)
const medicalConditions = form.getAll('medical_conditions').map(String)
const allergyOther = String(form.get('allergies_other') ?? '').trim()
const medicalOther = String(form.get('medical_conditions_other') ?? '').trim()
if (allergyOther) allergies.push(allergyOther)
if (medicalOther) medicalConditions.push(medicalOther)
await updateParentStudent(student.id, {
firstname: String(form.get('firstname') ?? ''),
lastname: String(form.get('lastname') ?? ''),
dob: String(form.get('dob') ?? ''),
gender: String(form.get('gender') ?? ''),
registration_grade: String(form.get('registration_grade') ?? ''),
photo_consent: form.get('photo_consent') === 'Yes',
allergies,
medical_conditions: medicalConditions,
})
setStatus('Student updated.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update student.')
}
}
return (
<ParentParityShell
title="Edit student"
ciViewFile="edit_student_modal.php"
@@ -51,48 +236,80 @@ export function ParentStudentEditPage() {
`/parent/edit_student/${studentId ?? ''}`,
`/parent/edit-student/${studentId ?? ''}`,
]}
>
<p className="small mb-0">
Student id: <code>{studentId}</code>. Wire to{' '}
<code>PATCH /api/v1/parents/students/{'{studentId}'}</code>.
</p>
</ParentParityShell>
)
}
>
<CiFlashMessages
messages={[
...(status ? [{ type: 'success' as const, message: status }] : []),
...(error ? [{ type: 'warning' as const, message: error }] : []),
]}
/>
{loading ? <p className="text-muted small">Loading</p> : null}
{!loading && !student ? <div className="alert alert-info small">Student not found.</div> : null}
{student ? (
<form onSubmit={onSubmit}>
<CiStudentFormPartial defaultValues={student} />
<button className="btn btn-success" type="submit">Save student</button>
</form>
) : null}
</ParentParityShell>
)
}
/** CI `parent/report-cards/view/(:num)` — report card PDF / viewer. */
export function ParentReportCardsViewPage() {
const { reportId } = useParams<{ reportId: string }>()
return (
export function ParentReportCardsViewPage() {
const { reportId } = useParams<{ reportId: string }>()
const id = Number(reportId)
const [url, setUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
let objectUrl: string | null = null
;(async () => {
try {
const blob = await fetchReportCardPdf(id)
objectUrl = URL.createObjectURL(blob)
setUrl(objectUrl)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load report card.')
}
})()
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [id])
return (
<ParentParityShell
title="Report card"
ciViewFile="report_cards.php"
legacyCiRoutes={[`/parent/report-cards/view/${reportId ?? ''}`]}
>
<p className="small mb-0">
Report / student id: <code>{reportId}</code>. Laravel:{' '}
<code>/api/v1/reports/report-cards/students/{'{studentId}'}</code> (with parent auth).
</p>
</ParentParityShell>
)
}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
{!url && !error ? <p className="text-muted small">Loading report card</p> : null}
{url ? (
<iframe title="Report card" src={url} className="w-100 border rounded" style={{ minHeight: '75vh' }} />
) : null}
</ParentParityShell>
)
}
/** CI `parent/progress/attachment/(:num)` — file download route. */
export function ParentProgressAttachmentPage() {
export function ParentProgressAttachmentPage() {
const { attachmentId } = useParams<{ attachmentId: string }>()
return (
<ParentParityShell
title="Class progress attachment"
ciViewFile="class_progress_view.php"
legacyCiRoutes={[`/parent/progress/attachment/${attachmentId ?? ''}`]}
>
<p className="small mb-0">
Attachment id: <code>{attachmentId}</code>. Wire download to{' '}
<code>/api/v1/class-progress/attachments/{'{attachmentId}'}</code>.
</p>
</ParentParityShell>
)
}
>
<a className="btn btn-outline-primary" href={`/api/v1/class-progress/attachments/${attachmentId}`}>
Download attachment
</a>
</ParentParityShell>
)
}
/** CI `parent/progress/attachment-file/(:num)` — legacy file stream. */
export function ParentProgressAttachmentFilePage() {
@@ -102,28 +319,53 @@ export function ParentProgressAttachmentFilePage() {
title="Class progress attachment (legacy)"
ciViewFile="class_progress_view.php"
legacyCiRoutes={[`/parent/progress/attachment-file/${fileId ?? ''}`]}
>
<p className="small mb-0">
File id: <code>{fileId}</code>. Legacy path uses{' '}
<code>/api/v1/class-progress/{'{id}'}/attachment</code>.
</p>
</ParentParityShell>
)
}
>
<a className="btn btn-outline-primary" href={`/api/v1/class-progress/${fileId}/attachment`}>
Download legacy attachment
</a>
</ParentParityShell>
)
}
/** CI `POST parent/report-cards/sign/(:num)` — acknowledgement / signature flow. */
export function ParentReportCardsSignPage() {
const { studentId } = useParams<{ studentId: string }>()
return (
export function ParentReportCardsSignPage() {
const { studentId } = useParams<{ studentId: string }>()
const id = Number(studentId)
const [ack, setAck] = useState<ReportCardAcknowledgementRow | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
let cancelled = false
;(async () => {
try {
const res = await fetchReportCardAcknowledgement(id)
if (!cancelled) setAck(res.data ?? null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load acknowledgement.')
}
})()
return () => {
cancelled = true
}
}, [id])
return (
<ParentParityShell
title="Sign report card"
ciViewFile="report_cards.php"
legacyCiRoutes={[`POST /parent/report-cards/sign/${studentId ?? ''}`]}
>
<p className="small mb-0">
Student id: <code>{studentId}</code>. Wire <code>POST</code> to Laravel report-cards
acknowledgement endpoints under <code>/api/v1/reports/report-cards/</code>.
</p>
</ParentParityShell>
)
}
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
<dl className="row small">
<dt className="col-sm-3">Student ID</dt><dd className="col-sm-9">{studentId}</dd>
<dt className="col-sm-3">Viewed</dt><dd className="col-sm-9">{fieldValue(ack?.viewed_at)}</dd>
<dt className="col-sm-3">Signed</dt><dd className="col-sm-9">{fieldValue(ack?.signed_at)}</dd>
<dt className="col-sm-3">Signed name</dt><dd className="col-sm-9">{fieldValue(ack?.signed_name)}</dd>
</dl>
<Link className="btn btn-outline-primary" to={`/app/parent/report-cards/view/${studentId}`}>
View report card
</Link>
</ParentParityShell>
)
}
+212 -29
View File
@@ -1,29 +1,212 @@
import { createParentParityPage } from './createParentParityPage'
/** CI `parent/edit-all-students` — bulk edit modal (composed from registration views). */
export const ParentEditAllStudentsPage = createParentParityPage({
title: 'Edit all students',
ciViewFile: 'register_student.php',
legacyCiRoutes: ['/parent/edit-all-students'],
})
/** CI `parent/add-emergency-form` — partial / modal flow. */
export const ParentAddEmergencyFormPage = createParentParityPage({
title: 'Add emergency contact',
ciViewFile: 'edit_emergency_contact.php',
legacyCiRoutes: ['/parent/add-emergency-form'],
})
/** CI `parent/add-student-form` — add student flow. */
export const ParentAddStudentFormPage = createParentParityPage({
title: 'Add student',
ciViewFile: 'register_student.php',
legacyCiRoutes: ['/parent/add-student-form'],
})
/** POST `parent/updateProfile/(:num)` — profile updates (PATCH in Laravel API). */
export const ParentUpdateProfileParityPage = createParentParityPage({
title: 'Update profile',
ciViewFile: 'register_student.php',
legacyCiRoutes: ['POST /parent/updateProfile/:id'],
})
import { useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import {
fetchParentProfile,
fetchParentRegistrationOverview,
storeParentEmergencyContact,
storeParentRegistration,
updateParentProfile,
} from '../../api/session'
import type { ParentEnrollmentStudent, ParentProfileRecord } from '../../api/types'
import { CiEmergencyContactFormPartial, CiFlashMessages, CiStandardStudentTable, CiStudentFormPartial } from '../../components/CiPartials'
import { ParentParityShell } from './ParentParityShell'
function Notice({ message, type = 'success' }: { message: string | null; type?: 'success' | 'warning' }) {
return message ? <CiFlashMessages messages={[{ type, message }]} /> : null
}
export function ParentEditAllStudentsPage() {
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetchParentRegistrationOverview()
if (!cancelled) setStudents(res.existingKids ?? [])
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load students.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
return (
<ParentParityShell title="Edit all students" ciViewFile="register_student.php" legacyCiRoutes={['/parent/edit-all-students']}>
<Notice message={error} type="warning" />
{loading ? (
<p className="text-muted small">Loading...</p>
) : (
<CiStandardStudentTable
rows={students.map((student) => ({
...student,
first_name: student.firstname,
last_name: student.lastname,
Grade: student.registration_grade,
}))}
customHeaders={['Class']}
customKeys={['class_section']}
emptyText="No students found."
/>
)}
{!loading && students.length > 0 ? (
<div className="d-flex flex-wrap gap-2">
{students.map((student) => (
<Link key={student.id} className="btn btn-sm btn-outline-primary" to={`/app/parent/students/${student.id}/edit`}>
Edit {student.firstname}
</Link>
))}
</div>
) : null}
</ParentParityShell>
)
}
export function ParentAddEmergencyFormPage() {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = new FormData(e.currentTarget)
setStatus(null)
setError(null)
try {
await storeParentEmergencyContact({
name: String(form.get('name') ?? ''),
cellphone: String(form.get('cellphone') ?? ''),
email: String(form.get('email') ?? ''),
relation: String(form.get('relation') ?? ''),
})
setStatus('Emergency contact added.')
e.currentTarget.reset()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add contact.')
}
}
return (
<ParentParityShell title="Add emergency contact" ciViewFile="edit_emergency_contact.php" legacyCiRoutes={['/parent/add-emergency-form']}>
<Notice message={status} />
<Notice message={error} type="warning" />
<form onSubmit={onSubmit}>
<CiEmergencyContactFormPartial />
<button className="btn btn-success" type="submit">Add contact</button>
</form>
</ParentParityShell>
)
}
export function ParentAddStudentFormPage() {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = new FormData(e.currentTarget)
setStatus(null)
setError(null)
try {
const allergies = form.getAll('allergies').map(String)
const medicalConditions = form.getAll('medical_conditions').map(String)
const allergyOther = String(form.get('allergies_other') ?? '').trim()
const medicalOther = String(form.get('medical_conditions_other') ?? '').trim()
if (allergyOther) allergies.push(allergyOther)
if (medicalOther) medicalConditions.push(medicalOther)
await storeParentRegistration({
students: [{
firstname: String(form.get('firstname') ?? ''),
lastname: String(form.get('lastname') ?? ''),
dob: String(form.get('dob') ?? ''),
gender: String(form.get('gender') ?? ''),
registration_grade: String(form.get('registration_grade') ?? ''),
photo_consent: form.get('photo_consent') === 'Yes',
is_new: true,
allergies,
medical_conditions: medicalConditions,
}],
})
setStatus('Student added.')
e.currentTarget.reset()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add student.')
}
}
return (
<ParentParityShell title="Add student" ciViewFile="register_student.php" legacyCiRoutes={['/parent/add-student-form']}>
<Notice message={status} />
<Notice message={error} type="warning" />
<form onSubmit={onSubmit}>
<CiStudentFormPartial />
<button className="btn btn-success" type="submit">Add student</button>
</form>
</ParentParityShell>
)
}
export function ParentUpdateProfileParityPage() {
const [profile, setProfile] = useState<ParentProfileRecord | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetchParentProfile()
if (!cancelled) setProfile(res.profile)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load profile.')
}
})()
return () => {
cancelled = true
}
}, [])
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = new FormData(e.currentTarget)
setStatus(null)
setError(null)
try {
const res = await updateParentProfile({
firstname: String(form.get('firstname') ?? ''),
lastname: String(form.get('lastname') ?? ''),
cellphone: String(form.get('cellphone') ?? ''),
address_street: String(form.get('address_street') ?? ''),
city: String(form.get('city') ?? ''),
state: String(form.get('state') ?? ''),
zip: String(form.get('zip') ?? ''),
})
setProfile(res.profile)
setStatus('Profile updated.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile.')
}
}
return (
<ParentParityShell title="Update profile" ciViewFile="register_student.php" legacyCiRoutes={['PATCH /api/v1/parents/profile']}>
<Notice message={status} />
<Notice message={error} type="warning" />
<form className="row g-3" onSubmit={onSubmit}>
<div className="col-md-6"><label className="form-label">First name</label><input name="firstname" className="form-control" defaultValue={profile?.firstname ?? ''} required /></div>
<div className="col-md-6"><label className="form-label">Last name</label><input name="lastname" className="form-control" defaultValue={profile?.lastname ?? ''} required /></div>
<div className="col-md-6"><label className="form-label">Cellphone</label><input name="cellphone" className="form-control" defaultValue={profile?.cellphone ?? ''} required /></div>
<div className="col-md-6"><label className="form-label">Street</label><input name="address_street" className="form-control" defaultValue={profile?.address_street ?? ''} required /></div>
<div className="col-md-4"><label className="form-label">City</label><input name="city" className="form-control" defaultValue={profile?.city ?? ''} required /></div>
<div className="col-md-4"><label className="form-label">State</label><input name="state" className="form-control" defaultValue={profile?.state ?? ''} required /></div>
<div className="col-md-4"><label className="form-label">Zip</label><input name="zip" className="form-control" defaultValue={profile?.zip ?? ''} required /></div>
<div className="col-12"><button className="btn btn-success" type="submit">Save profile</button></div>
</form>
</ParentParityShell>
)
}
File diff suppressed because it is too large Load Diff