816f4cfaf0
- Move SortTh outside AdminEmergencyContactIndexPage to fix react-hooks/static-components (5 errors) - Fix ternary expressions used as statements in toggleSection and toggleDate (2 @typescript-eslint/no-unused-expressions errors) - Remove stale eslint-disable-next-line react/no-danger comment referencing unregistered rule (1 error) - Fix pre-existing any type in WiredParentScreens student.map
547 lines
19 KiB
TypeScript
547 lines
19 KiB
TypeScript
import { useEffect, useState, type FormEvent } from 'react'
|
|
import { ParentParityShell } from './ParentParityShell'
|
|
import {
|
|
fetchAttendanceReportForm,
|
|
fetchParentEnrollments,
|
|
fetchParentEventsOverview,
|
|
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 */
|
|
export function ParentContactPage() {
|
|
const { user } = useAuth()
|
|
const [name, setName] = useState('')
|
|
const [email, setEmail] = useState('')
|
|
const [subject, setSubject] = useState('')
|
|
const [message, setMessage] = useState('')
|
|
const [status, setStatus] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
try {
|
|
const res = await fetchParentProfile()
|
|
const p = res.profile
|
|
if (cancelled || !p) return
|
|
const fn = (p.firstname ?? '').trim()
|
|
const ln = (p.lastname ?? '').trim()
|
|
const full = `${fn} ${ln}`.trim()
|
|
if (full) setName(full)
|
|
if (p.email) setEmail(String(p.email))
|
|
} catch {
|
|
/* optional */
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!user?.name) return
|
|
setName((n) => n || user.name)
|
|
}, [user?.name])
|
|
|
|
async function onSubmit(e: FormEvent) {
|
|
e.preventDefault()
|
|
setLoading(true)
|
|
setError(null)
|
|
setStatus(null)
|
|
try {
|
|
const res = await submitContactMessage({ name, email, subject, message })
|
|
if (res.status) {
|
|
setStatus(res.message ?? 'Message sent.')
|
|
setSubject('')
|
|
setMessage('')
|
|
} else {
|
|
setError('Could not send message.')
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Request failed.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ParentParityShell
|
|
title="Contact"
|
|
ciViewFile="contact.php"
|
|
legacyCiRoutes={['/parent/contact']}
|
|
>
|
|
{status ? <div className="alert alert-success small">{status}</div> : null}
|
|
{error ? <div className="alert alert-danger small">{error}</div> : null}
|
|
<form className="row g-3" onSubmit={onSubmit}>
|
|
<div className="col-md-6">
|
|
<label className="form-label" htmlFor="pc_name">
|
|
Name
|
|
</label>
|
|
<input
|
|
id="pc_name"
|
|
className="form-control"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
minLength={3}
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label" htmlFor="pc_email">
|
|
Email
|
|
</label>
|
|
<input
|
|
id="pc_email"
|
|
type="email"
|
|
className="form-control"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-12">
|
|
<label className="form-label" htmlFor="pc_subject">
|
|
Subject
|
|
</label>
|
|
<input
|
|
id="pc_subject"
|
|
className="form-control"
|
|
value={subject}
|
|
onChange={(e) => setSubject(e.target.value)}
|
|
required
|
|
minLength={3}
|
|
/>
|
|
</div>
|
|
<div className="col-12">
|
|
<label className="form-label" htmlFor="pc_message">
|
|
Message
|
|
</label>
|
|
<textarea
|
|
id="pc_message"
|
|
className="form-control"
|
|
rows={5}
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
required
|
|
minLength={10}
|
|
/>
|
|
</div>
|
|
<div className="col-12">
|
|
<button type="submit" className="btn btn-success" disabled={loading}>
|
|
{loading ? 'Sending…' : 'Send'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</ParentParityShell>
|
|
)
|
|
}
|
|
|
|
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
|
|
export function ParentEnrollClassesPage() {
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
|
const [year, setYear] = useState<string | null>(null)
|
|
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await fetchParentEnrollments(null)
|
|
if (cancelled) return
|
|
setSchoolYears(data.schoolYears ?? [])
|
|
setYear(data.selectedYear ?? null)
|
|
setStudents(data.students ?? [])
|
|
setError(null)
|
|
} catch (e) {
|
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<ParentParityShell
|
|
title="Enroll in classes"
|
|
ciViewFile="enroll_classes.php"
|
|
legacyCiRoutes={['/parent/enroll_classes']}
|
|
>
|
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
|
{loading ? (
|
|
<p className="text-muted small">Loading…</p>
|
|
) : (
|
|
<>
|
|
<p className="small text-muted mb-2">
|
|
School year: <code>{year ?? '—'}</code>. Updates use{' '}
|
|
<code>POST /api/v1/parents/enrollments</code> with enroll / withdraw payloads.
|
|
</p>
|
|
<div className="table-responsive">
|
|
<table className="table table-sm table-bordered">
|
|
<thead>
|
|
<tr>
|
|
<th>Student</th>
|
|
<th>Class</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{students.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={2} className="text-muted">
|
|
No students for this overview.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
students.map((s) => (
|
|
<tr key={s.id}>
|
|
<td>
|
|
{s.firstname} {s.lastname}
|
|
</td>
|
|
<td>{s.class_section ?? '—'}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{schoolYears.length > 1 ? (
|
|
<p className="small mb-0">Additional years in response: {schoolYears.length}</p>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</ParentParityShell>
|
|
)
|
|
}
|
|
|
|
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
|
|
export function ParentPaymentInvoicesPage() {
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await fetchParentInvoices(null)
|
|
if (cancelled) return
|
|
setRows(data.invoices ?? [])
|
|
setError(null)
|
|
} catch (e) {
|
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load invoices.')
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<ParentParityShell
|
|
title="Payments & invoices"
|
|
ciViewFile="payment_view.php"
|
|
legacyCiRoutes={['/parent/payment']}
|
|
>
|
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
|
{loading ? (
|
|
<p className="text-muted small">Loading…</p>
|
|
) : (
|
|
<div className="table-responsive">
|
|
<table className="table table-sm table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Invoice</th>
|
|
<th>Balance</th>
|
|
<th>Status</th>
|
|
<th>Due</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="text-muted">
|
|
No invoices.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
rows.map((inv) => (
|
|
<tr key={inv.id}>
|
|
<td>{inv.id}</td>
|
|
<td>{inv.invoice_number ?? '—'}</td>
|
|
<td>
|
|
{typeof inv.balance === 'number' ? inv.balance.toFixed(2) : '—'}
|
|
</td>
|
|
<td>{inv.status ?? '—'}</td>
|
|
<td>{inv.due_date ?? '—'}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</ParentParityShell>
|
|
)
|
|
}
|
|
|
|
/** `Views/parent/event_participation.php` — GET/POST /api/v1/parents/events … */
|
|
export function ParentEventsPage() {
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [data, setData] = useState<ParentEventsOverviewResponse | null>(null)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
setLoading(true)
|
|
try {
|
|
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.')
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<ParentParityShell
|
|
title="Event participation"
|
|
ciViewFile="event_participation.php"
|
|
legacyCiRoutes={['/parent/events']}
|
|
>
|
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
|
{loading ? (
|
|
<p className="text-muted small">Loading…</p>
|
|
) : (
|
|
<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 [data, setData] = useState<ParentRegistrationOverviewResponse | null>(null)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
setLoading(true)
|
|
try {
|
|
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.')
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<ParentParityShell
|
|
title="Register student"
|
|
ciViewFile="register_student.php"
|
|
legacyCiRoutes={['/parent/register_student', '/parent/child_register']}
|
|
>
|
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
|
{loading ? (
|
|
<p className="text-muted small">Loading…</p>
|
|
) : (
|
|
<>
|
|
<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 [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
|
|
setData(data)
|
|
setError(null)
|
|
} catch (e) {
|
|
if (!cancelled)
|
|
setError(e instanceof Error ? e.message : 'Failed to load attendance report form.')
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<ParentParityShell
|
|
title="Report attendance"
|
|
ciViewFile="report_attendance.php"
|
|
legacyCiRoutes={['/parent/report-attendance']}
|
|
>
|
|
{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: { id: number; firstname: string; lastname: string }) => (
|
|
<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>
|
|
)
|
|
}
|