add parent pages
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -9,8 +9,6 @@ export function CiPlaceholderPage() {
|
||||
const meta = getCiMeta(star)
|
||||
|
||||
const title = meta?.title ?? 'Section placeholder'
|
||||
const viewsPath = meta?.ciViewPath ?? (star ? `…/${star}` : '…')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
@@ -31,12 +29,7 @@ export function CiPlaceholderPage() {
|
||||
</nav>
|
||||
|
||||
<h2 className="h4 mb-3">{title}</h2>
|
||||
<p className="text-muted mb-2">
|
||||
CodeIgniter view:{' '}
|
||||
<code>
|
||||
app/Views/{viewsPath}
|
||||
</code>
|
||||
</p>
|
||||
|
||||
<p className="text-muted small mb-3">
|
||||
Implement this screen using the matching routes in <code>app_laravel/routes/api.php</code>{' '}
|
||||
(JWT <code>Authorization: Bearer</code>). See also <Link to="/docs">API docs</Link>.
|
||||
@@ -47,7 +40,7 @@ export function CiPlaceholderPage() {
|
||||
<p className="small mb-0">
|
||||
<Link to="/app/browse">Browse all CI view routes</Link>
|
||||
{' · '}
|
||||
<Link to="/app/home">App home</Link>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -30,7 +30,6 @@ export function CiSitemapPage() {
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h2 className="h4 mb-2">CodeIgniter views → SPA routes</h2>
|
||||
<p className="text-muted small mb-4">
|
||||
Generated from <code>app_codeigniter/app/Views/**/*.php</code>. Each link opens the parity
|
||||
route under <code>/app/…</code>; unimplemented screens use the section placeholder with the
|
||||
|
||||
@@ -31,8 +31,16 @@ export function LoginPage() {
|
||||
setError(result.message)
|
||||
return
|
||||
}
|
||||
if (result.requiresRoleSelection) {
|
||||
navigate('/app/select-role', { replace: true, state: { from } })
|
||||
return
|
||||
}
|
||||
|
||||
const isParentLogin =
|
||||
result.selectedRole?.toLowerCase() === 'parent' ||
|
||||
result.roles.some((role) => role.toLowerCase() === 'parent')
|
||||
let target = from.startsWith('/app') ? from : '/app/home'
|
||||
if (isParentLogin && target === '/app/home') target = '/app/parent/home'
|
||||
try {
|
||||
const dash = await fetchDashboardRoute()
|
||||
const route = dash.data?.dashboard?.route
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
deleteNavBuilderItem,
|
||||
fetchNavBuilderData,
|
||||
reorderNavBuilderItems,
|
||||
saveNavBuilderItem,
|
||||
} from '../api/session'
|
||||
import type { NavBuilderData, NavBuilderItem } from '../api/types'
|
||||
import { CiFlashMessages, type CiFlashMessage } from '../components/CiPartials'
|
||||
|
||||
type NavFormState = {
|
||||
id: string
|
||||
menu_parent_id: string
|
||||
label: string
|
||||
url: string
|
||||
icon_class: string
|
||||
target: string
|
||||
sort_order: string
|
||||
is_enabled: boolean
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
const blankForm: NavFormState = {
|
||||
id: '',
|
||||
menu_parent_id: '',
|
||||
label: '',
|
||||
url: '',
|
||||
icon_class: '',
|
||||
target: '',
|
||||
sort_order: '0',
|
||||
is_enabled: true,
|
||||
roles: [],
|
||||
}
|
||||
|
||||
export function NavBuilderPage() {
|
||||
const [payload, setPayload] = useState<NavBuilderData>({
|
||||
items: [],
|
||||
roles: [],
|
||||
parentOptions: [],
|
||||
})
|
||||
const [form, setForm] = useState<NavFormState>(blankForm)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [messages, setMessages] = useState<CiFlashMessage[]>([])
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchNavBuilderData()
|
||||
setPayload(res.data?.data ?? { items: [], roles: [], parentOptions: [] })
|
||||
setMessages([])
|
||||
} catch (error) {
|
||||
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Failed to load menu items.' }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const filteredParentOptions = useMemo(
|
||||
() => payload.parentOptions.filter((option) => String(option.id) !== form.id),
|
||||
[form.id, payload.parentOptions],
|
||||
)
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setMessages([])
|
||||
try {
|
||||
await saveNavBuilderItem({
|
||||
id: form.id ? Number(form.id) : null,
|
||||
menu_parent_id: form.menu_parent_id ? Number(form.menu_parent_id) : null,
|
||||
label: form.label.trim(),
|
||||
url: form.url.trim() || null,
|
||||
icon_class: form.icon_class.trim() || null,
|
||||
target: form.target.trim() || null,
|
||||
sort_order: Number(form.sort_order) || 0,
|
||||
is_enabled: form.is_enabled,
|
||||
roles: form.roles.map(Number).filter((id) => id > 0),
|
||||
})
|
||||
setForm(blankForm)
|
||||
setMessages([{ type: 'success', message: 'Menu saved.' }])
|
||||
await load()
|
||||
} catch (error) {
|
||||
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to save menu item.' }])
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(item: NavBuilderItem) {
|
||||
if (!window.confirm(`Delete "${item.label}"?`)) return
|
||||
setMessages([])
|
||||
try {
|
||||
await deleteNavBuilderItem(item.id)
|
||||
setMessages([{ type: 'success', message: 'Menu item deleted.' }])
|
||||
if (form.id === String(item.id)) setForm(blankForm)
|
||||
await load()
|
||||
} catch (error) {
|
||||
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to delete menu item.' }])
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveOrder() {
|
||||
const orders = Object.fromEntries(
|
||||
payload.items.map((item) => [String(item.id), Number(item.sort_order ?? item.order ?? 0)]),
|
||||
)
|
||||
setMessages([])
|
||||
try {
|
||||
await reorderNavBuilderItems(orders)
|
||||
setMessages([{ type: 'success', message: 'Menu order saved.' }])
|
||||
await load()
|
||||
} catch (error) {
|
||||
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to reorder menu items.' }])
|
||||
}
|
||||
}
|
||||
|
||||
function editItem(item: NavBuilderItem) {
|
||||
setForm({
|
||||
id: String(item.id),
|
||||
menu_parent_id: item.menu_parent_id == null ? '' : String(item.menu_parent_id),
|
||||
label: item.label ?? '',
|
||||
url: item.url ?? '',
|
||||
icon_class: item.icon_class ?? '',
|
||||
target: item.target ?? '',
|
||||
sort_order: String(item.sort_order ?? item.order ?? 0),
|
||||
is_enabled: Boolean(item.is_enabled ?? item.enabled),
|
||||
roles: item.roles?.ids?.map(String) ?? [],
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid px-0 nav-builder-page">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h3 className="mb-1">Navbar Builder</h3>
|
||||
</div>
|
||||
<button className="btn btn-outline-secondary btn-sm" type="button" onClick={() => void load()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CiFlashMessages messages={messages} />
|
||||
|
||||
<div className="card mb-4 rounded-0 border-0" id="menuTableCard">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Current Menu</span>
|
||||
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void onSaveOrder()}>
|
||||
Save order
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div id="menuTableWrap" className="table-responsive">
|
||||
<table id="menuTable" className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Actions</th>
|
||||
<th>Label</th>
|
||||
<th>URL</th>
|
||||
<th>Parent</th>
|
||||
<th>Roles</th>
|
||||
<th>Order</th>
|
||||
<th>Enabled</th>
|
||||
<th>Depth</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center text-muted">Loading menu...</td>
|
||||
</tr>
|
||||
) : payload.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center text-muted">No menu items found.</td>
|
||||
</tr>
|
||||
) : (
|
||||
payload.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="text-nowrap">
|
||||
<button className="btn btn-sm btn-outline-primary me-1" type="button" onClick={() => editItem(item)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" type="button" onClick={() => void onDelete(item)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-muted">{'- '.repeat(item.depth ?? 0)}</span>
|
||||
{item.label}
|
||||
{item.icon_class ? <code className="ms-2 small">{item.icon_class}</code> : null}
|
||||
</td>
|
||||
<td>{item.url ? <code>{item.url}</code> : <span className="text-muted">-</span>}</td>
|
||||
<td>{parentLabel(item, payload.items)}</td>
|
||||
<td>
|
||||
{(item.roles?.names ?? []).length > 0 ? (
|
||||
item.roles?.names?.map((roleName) => (
|
||||
<span key={roleName} className="badge badge-role me-1 mb-1">
|
||||
{roleName}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="form-control form-control-sm nav-builder-order"
|
||||
type="number"
|
||||
value={item.sort_order ?? item.order ?? 0}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value) || 0
|
||||
setPayload((current) => ({
|
||||
...current,
|
||||
items: current.items.map((row) => (row.id === item.id ? { ...row, sort_order: next } : row)),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{Boolean(item.is_enabled ?? item.enabled) ? (
|
||||
<span className="badge bg-success">Yes</span>
|
||||
) : (
|
||||
<span className="badge bg-light text-dark">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{item.depth ?? 0}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-lg-5">
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">Add / Edit Item</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="mb-2">
|
||||
<label className="form-label" htmlFor="menu_parent_id">Parent</label>
|
||||
<select
|
||||
name="menu_parent_id"
|
||||
id="menu_parent_id"
|
||||
className="form-select"
|
||||
value={form.menu_parent_id}
|
||||
onChange={(event) => setForm((current) => ({ ...current, menu_parent_id: event.target.value }))}
|
||||
>
|
||||
<option value="">- Top level -</option>
|
||||
{filteredParentOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<label className="form-label" htmlFor="label">Label</label>
|
||||
<input
|
||||
className="form-control"
|
||||
name="label"
|
||||
id="label"
|
||||
value={form.label}
|
||||
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<label className="form-label" htmlFor="url">URL (relative or absolute)</label>
|
||||
<input
|
||||
className="form-control"
|
||||
name="url"
|
||||
id="url"
|
||||
placeholder="e.g. administrator/student_profiles"
|
||||
value={form.url}
|
||||
onChange={(event) => setForm((current) => ({ ...current, url: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-4 mb-2">
|
||||
<label className="form-label" htmlFor="target">Target</label>
|
||||
<select
|
||||
name="target"
|
||||
id="target"
|
||||
className="form-select"
|
||||
value={form.target}
|
||||
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
|
||||
>
|
||||
<option value="">(default)</option>
|
||||
<option value="_blank">_blank</option>
|
||||
<option value="_self">_self</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-4 mb-2">
|
||||
<label className="form-label" htmlFor="sort_order">Order</label>
|
||||
<input
|
||||
className="form-control"
|
||||
type="number"
|
||||
name="sort_order"
|
||||
id="sort_order"
|
||||
value={form.sort_order}
|
||||
onChange={(event) => setForm((current) => ({ ...current, sort_order: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4 mb-2 d-flex align-items-end">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
name="is_enabled"
|
||||
id="is_enabled"
|
||||
checked={form.is_enabled}
|
||||
onChange={(event) => setForm((current) => ({ ...current, is_enabled: event.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="is_enabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<label className="form-label" htmlFor="icon_class">Icon class</label>
|
||||
<input
|
||||
className="form-control"
|
||||
name="icon_class"
|
||||
id="icon_class"
|
||||
placeholder="e.g. fas fa-user-graduate"
|
||||
value={form.icon_class}
|
||||
onChange={(event) => setForm((current) => ({ ...current, icon_class: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<label className="form-label" htmlFor="roles">Roles (who can see)</label>
|
||||
<select
|
||||
name="roles"
|
||||
id="roles"
|
||||
className="form-select"
|
||||
multiple
|
||||
size={6}
|
||||
value={form.roles}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
roles: Array.from(event.target.selectedOptions).map((option) => option.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{payload.roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>{role.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<small className="text-muted">Hold Ctrl/Cmd to select multiple.</small>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>{' '}
|
||||
<button className="btn btn-secondary" type="button" onClick={() => setForm(blankForm)}>
|
||||
Reset
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parentLabel(item: NavBuilderItem, items: NavBuilderItem[]) {
|
||||
if (item.parent_label) return item.parent_label
|
||||
const parentId = item.menu_parent_id ?? item.parent_id
|
||||
if (!parentId) return '-'
|
||||
return items.find((row) => row.id === parentId)?.label ?? `#${parentId}`
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchDashboardRoute } from '../api/session'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
|
||||
|
||||
export function RoleSelectPage() {
|
||||
const { user, roles, selectedRole, setSelectedRole } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [pickedRole, setPickedRole] = useState<string | null>(selectedRole)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
|
||||
|
||||
async function continueLogin() {
|
||||
if (!pickedRole) {
|
||||
setError('Please select a role to continue.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setSelectedRole(pickedRole)
|
||||
|
||||
let target = from.startsWith('/app') ? from : '/app/home'
|
||||
if (pickedRole.toLowerCase() === 'parent' && target === '/app/home') {
|
||||
target = '/app/parent/home'
|
||||
}
|
||||
try {
|
||||
const dash = await fetchDashboardRoute()
|
||||
const route = dash.data?.dashboard?.route
|
||||
const mapped = spaPathFromCiUrl(route)
|
||||
if (mapped) target = mapped
|
||||
} catch {
|
||||
/* keep fallback target */
|
||||
}
|
||||
|
||||
navigate(target, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-4">
|
||||
<div className="card border-0 shadow-sm mx-auto" style={{ maxWidth: 640 }}>
|
||||
<div className="card-body p-4">
|
||||
<h2 className="h4 mb-2">Select role</h2>
|
||||
<p className="text-muted mb-3">
|
||||
{user?.name ? `Signed in as ${user.name}.` : 'Signed in successfully.'} Choose a role
|
||||
to continue.
|
||||
</p>
|
||||
|
||||
<div className="list-group mb-3">
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action text-start ${
|
||||
pickedRole === role ? 'active' : ''
|
||||
}`}
|
||||
onClick={() => setPickedRole(role)}
|
||||
>
|
||||
{role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="d-grid">
|
||||
<button type="button" className="btn btn-success" onClick={continueLogin} disabled={busy}>
|
||||
{busy ? 'Continuing…' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { 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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { fetchParentAttendance } from '../../api/session'
|
||||
import type { ParentAttendanceRow } from '../../api/types'
|
||||
|
||||
@@ -133,9 +133,9 @@ export function ParentAttendancePage() {
|
||||
)}
|
||||
|
||||
<p className="small text-muted mt-4 mb-0">
|
||||
<Link to="/app/parent/home">Parent dashboard</Link>
|
||||
|
||||
{' · '}
|
||||
<Link to="/app/home">App home</Link>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchParentProfile } from '../../api/session'
|
||||
import type { ParentProfileRecord } from '../../api/types'
|
||||
import { 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user