fix teacher, parent and admin pages
This commit is contained in:
@@ -1,24 +1,20 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
||||
/**
|
||||
* Barrel: class progress + student score card live under `pages/admin/`.
|
||||
* Emergency contact admin screens remain here.
|
||||
*/
|
||||
export { ClassProgressListPage as AdminClassProgressListPage } from './classProgress/ClassProgressListPage'
|
||||
export { ClassProgressViewPage as AdminClassProgressViewPage } from './classProgress/ClassProgressViewPage'
|
||||
export { AdminStudentScoreCardPage } from './admin/AdminStudentScoreCardPage'
|
||||
|
||||
import { type FormEvent, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
deleteAdministratorEmergencyContact,
|
||||
fetchAdministratorEmergencyContact,
|
||||
fetchAdministratorEmergencyContacts,
|
||||
fetchClassProgressDetail,
|
||||
fetchClassProgressGroups,
|
||||
fetchClassProgressMeta,
|
||||
fetchClassSections,
|
||||
fetchStudentScoreCard,
|
||||
searchAdministratorDashboard,
|
||||
updateAdministratorEmergencyContact,
|
||||
} from '../api/session'
|
||||
import type {
|
||||
AdministratorDashboardSearchResponse,
|
||||
ClassProgressGroupRow,
|
||||
ClassProgressReportRow,
|
||||
EmergencyContactGroupRow,
|
||||
StudentScoreCardRow,
|
||||
} from '../api/types'
|
||||
import type { EmergencyContactGroupRow } from '../api/types'
|
||||
|
||||
function PageShell({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -31,476 +27,6 @@ function PageShell({ title, children }: { title: string; children: ReactNode })
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null, withTime = false) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return withTime ? date.toLocaleString() : date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function splitUnitTitle(unitTitle?: string | null) {
|
||||
const raw = String(unitTitle ?? '').trim()
|
||||
if (!raw) return { curriculum: [] as string[], custom: [] as string[] }
|
||||
const segments = raw
|
||||
.split(/\s*[;|]\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
const curriculum: string[] = []
|
||||
const custom: string[] = []
|
||||
for (const segment of segments) {
|
||||
if (/custom/i.test(segment)) custom.push(segment.replace(/^custom[:\s-]*/i, '').trim() || segment)
|
||||
else curriculum.push(segment)
|
||||
}
|
||||
if (curriculum.length === 0 && custom.length === 0) curriculum.push(raw)
|
||||
return { curriculum, custom }
|
||||
}
|
||||
|
||||
function ClassProgressUnitDisplay({
|
||||
unitTitle,
|
||||
isQuran,
|
||||
compact,
|
||||
}: {
|
||||
unitTitle?: string | null
|
||||
isQuran?: boolean
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { curriculum, custom } = splitUnitTitle(unitTitle)
|
||||
const labelCurriculum = isQuran ? 'Surah / curriculum' : 'Unit / chapter'
|
||||
const labelCustom = isQuran ? 'Custom Surah / Arabic' : 'Custom subject(s)'
|
||||
|
||||
if (!String(unitTitle ?? '').trim()) return <span className="text-muted">-</span>
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
{custom.length > 0 ? (
|
||||
<div className="small">
|
||||
<span className="badge text-bg-info me-1">{isQuran ? 'Custom' : 'Custom subject'}</span>
|
||||
{custom.join(', ')}
|
||||
</div>
|
||||
) : null}
|
||||
{curriculum.length > 0 ? (
|
||||
<div className={`small text-muted${custom.length > 0 ? ' mt-1' : ''}`}>{curriculum.join(' · ')}</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{curriculum.length > 0 ? <div className="mb-2"><strong>{labelCurriculum}:</strong> {curriculum.join(' ; ')}</div> : null}
|
||||
{custom.length > 0 ? <div className="mb-2"><strong>{labelCustom}:</strong> {custom.join(' ; ')}</div> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminClassProgressListPage() {
|
||||
const [filters, setFilters] = useState({ weekStart: '', weekEnd: '', classSectionId: '', status: '' })
|
||||
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
|
||||
const [statusOptions, setStatusOptions] = useState<Record<string, string>>({})
|
||||
const [classSections, setClassSections] = useState<Array<{ class_section_id?: number | null; class_section_name?: string | null }>>([])
|
||||
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [meta, sections, groups] = await Promise.all([
|
||||
fetchClassProgressMeta(),
|
||||
fetchClassSections({ perPage: 200 }),
|
||||
fetchClassProgressGroups({
|
||||
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
|
||||
status: filters.status || null,
|
||||
weekStart: filters.weekStart || null,
|
||||
weekEnd: filters.weekEnd || null,
|
||||
perPage: 200,
|
||||
}),
|
||||
])
|
||||
setSubjectSections(meta.data?.subject_sections ?? {})
|
||||
setStatusOptions(meta.data?.status_options ?? {})
|
||||
setClassSections(sections.data?.sections ?? [])
|
||||
setItems(groups.data?.items ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load class progress.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const itemsBySection = useMemo(() => {
|
||||
const map = new Map<string, ClassProgressGroupRow[]>()
|
||||
for (const item of items) {
|
||||
const key = String(item.class_section_id ?? '0')
|
||||
const bucket = map.get(key) ?? []
|
||||
bucket.push(item)
|
||||
map.set(key, bucket)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<PageShell title="Class Progress Reports">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<div className="d-flex align-items-center justify-content-between mb-4">
|
||||
<div className="text-muted">Filter by week, class, and status</div>
|
||||
<Link className="btn btn-sm btn-outline-warning" to="/app/administrator/teacher_submissions">
|
||||
Teachers < 50%
|
||||
</Link>
|
||||
</div>
|
||||
<form className="card shadow-sm mb-3" onSubmit={(e) => { e.preventDefault(); void load() }}>
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-3"><label className="form-label">From</label><input type="date" className="form-control" value={filters.weekStart} onChange={(e) => setFilters((current) => ({ ...current, weekStart: e.target.value }))} /></div>
|
||||
<div className="col-md-3"><label className="form-label">To</label><input type="date" className="form-control" value={filters.weekEnd} onChange={(e) => setFilters((current) => ({ ...current, weekEnd: e.target.value }))} /></div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Class/Section</label>
|
||||
<select className="form-select" value={filters.classSectionId} onChange={(e) => setFilters((current) => ({ ...current, classSectionId: e.target.value }))}>
|
||||
<option value="">All</option>
|
||||
{classSections.map((section) => <option key={String(section.class_section_id ?? '')} value={section.class_section_id ?? ''}>{section.class_section_name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Status</label>
|
||||
<select className="form-select" value={filters.status} onChange={(e) => setFilters((current) => ({ ...current, status: e.target.value }))}>
|
||||
<option value="">All</option>
|
||||
{Object.entries(statusOptions).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
<button className="btn btn-primary" type="submit">Apply</button>
|
||||
<button className="btn btn-outline-secondary" type="button" onClick={() => { setFilters({ weekStart: '', weekEnd: '', classSectionId: '', status: '' }); void setTimeout(load, 0) }}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card shadow-sm mt-4">
|
||||
<div className="card-body pt-4">
|
||||
{itemsBySection.length === 0 ? (
|
||||
<div className="text-center text-muted py-4">No reports found.</div>
|
||||
) : (
|
||||
<div className="accordion" id="adminProgressAccordion">
|
||||
{itemsBySection.map(([sectionId, groups]) => (
|
||||
<div className="accordion-item mb-2" key={sectionId}>
|
||||
<h2 className="accordion-header" id={`progress-heading-${sectionId}`}>
|
||||
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target={`#progress-section-${sectionId}`}>
|
||||
{groups[0]?.class_section_name ?? `Section ${sectionId}`}
|
||||
<span className="badge bg-secondary ms-2">{groups.length} weeks</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id={`progress-section-${sectionId}`} className="accordion-collapse collapse" data-bs-parent="#adminProgressAccordion">
|
||||
<div className="accordion-body">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover align-middle mb-0" data-no-mgmt-sticky>
|
||||
<thead className="table-light">
|
||||
<tr><th>Week</th><th>Subjects</th><th>Teacher</th><th className="text-end">Action</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group) => {
|
||||
const reports = group.reports ?? {}
|
||||
const teacherSet = Array.from(new Set(Object.values(reports).map((report) => report.teacher_name).filter(Boolean)))
|
||||
const firstReport = Object.values(reports)[0]
|
||||
return (
|
||||
<tr key={`${sectionId}-${group.week_start}`}>
|
||||
<td>
|
||||
<div className="fw-semibold">{formatDate(group.week_start)} - {formatDate(group.week_end)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{Object.entries(subjectSections).map(([slug, section]) => {
|
||||
const subjectName = section.db_subject ?? section.label ?? slug
|
||||
const report = reports[subjectName]
|
||||
return (
|
||||
<div key={slug} className="border rounded-3 p-2">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<strong className="small mb-0">{section.label ?? subjectName}</strong>
|
||||
<span className={`badge ${report ? 'bg-secondary' : 'bg-light text-muted'}`}>{report?.status_label ?? 'No entry'}</span>
|
||||
</div>
|
||||
<div className="small">
|
||||
{report ? (
|
||||
<ClassProgressUnitDisplay unitTitle={report.unit_title} isQuran={subjectName === 'Quran/Arabic'} compact />
|
||||
) : (
|
||||
<span className="text-muted">No submission</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td>{teacherSet.join(', ') || '—'}</td>
|
||||
<td className="text-end">
|
||||
{firstReport?.id ? (
|
||||
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => navigate(`/app/admin/progress/view/${firstReport.id}`)}>
|
||||
View
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminClassProgressViewPage() {
|
||||
const { id } = useParams()
|
||||
const reportId = Number(id)
|
||||
const [report, setReport] = useState<ClassProgressReportRow | null>(null)
|
||||
const [weeklyReports, setWeeklyReports] = useState<ClassProgressReportRow[]>([])
|
||||
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportId) return
|
||||
;(async () => {
|
||||
try {
|
||||
const [detail, meta] = await Promise.all([fetchClassProgressDetail(reportId), fetchClassProgressMeta()])
|
||||
setReport(detail.data?.report ?? null)
|
||||
setWeeklyReports(detail.data?.weekly_reports ?? [])
|
||||
setSubjectSections(meta.data?.subject_sections ?? {})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load progress report.')
|
||||
}
|
||||
})()
|
||||
}, [reportId])
|
||||
|
||||
const reportsBySubject = useMemo(
|
||||
() => Object.fromEntries(weeklyReports.map((entry) => [String(entry.subject ?? ''), entry])),
|
||||
[weeklyReports],
|
||||
)
|
||||
|
||||
const attachmentList = weeklyReports.flatMap((entry) => (entry.attachments ?? []).map((attachment) => ({
|
||||
subject: entry.subject ?? 'Attachment',
|
||||
attachment,
|
||||
})))
|
||||
|
||||
return (
|
||||
<PageShell title="Progress Report">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{report ? (
|
||||
<>
|
||||
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||
<div className="text-muted">{report.class_section_name ?? '-'} • {formatDate(report.week_start)} → {formatDate(report.week_end)}</div>
|
||||
<Link to="/app/admin/progress" className="btn btn-outline-secondary">Back</Link>
|
||||
</div>
|
||||
<div className="row g-3">
|
||||
<div className="col-lg-8">
|
||||
{Object.entries(subjectSections).map(([slug, section]) => {
|
||||
const subjectName = section.db_subject ?? section.label ?? slug
|
||||
const entry = reportsBySubject[subjectName]
|
||||
return (
|
||||
<div className="card shadow-sm mb-3" key={slug}>
|
||||
<div className="card-header bg-white"><strong>{section.label ?? subjectName}</strong></div>
|
||||
<div className="card-body">
|
||||
{!entry ? (
|
||||
<div className="text-muted">No entry submitted for this subject this week.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<ClassProgressUnitDisplay unitTitle={entry.unit_title} isQuran={subjectName === 'Quran/Arabic'} />
|
||||
</div>
|
||||
<div className="mb-3"><strong>Activities</strong><div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>{entry.covered || '-'}</div></div>
|
||||
<div className="mb-3"><strong>{subjectName === 'Quran/Arabic' ? 'Arabic Practice / Homework' : 'Assigned Homework'}:</strong><div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>{entry.homework || '-'}</div></div>
|
||||
{(entry.attachments ?? []).length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<strong>Attachments:</strong>
|
||||
<div className="d-flex flex-column gap-2 mt-2">
|
||||
{(entry.attachments ?? []).map((attachment) => (
|
||||
<a key={attachment.id ?? attachment.download_url} className="btn btn-sm btn-outline-secondary text-start" href={attachment.download_url ?? '#'} target="_blank" rel="noreferrer">
|
||||
{attachment.name ?? 'Attachment'}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="col-lg-4">
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-header bg-white"><strong>Summary</strong></div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2"><strong>Teacher:</strong> {report.teacher_name ?? '-'}</div>
|
||||
<div className="mb-2"><strong>Submitted:</strong> {formatDate(report.created_at, true)}</div>
|
||||
<div><strong>Status:</strong> {report.status_label ?? 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(report.flags ?? []).length > 0 ? (
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-header bg-white"><strong>Checklist</strong></div>
|
||||
<div className="card-body d-flex flex-wrap gap-2">
|
||||
{(report.flags ?? []).map((flag) => <span key={flag} className="badge bg-light text-dark border">{flag.replace(/_/g, ' ')}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{attachmentList.length > 0 ? (
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header bg-white"><strong>Attachments</strong></div>
|
||||
<div className="card-body">
|
||||
{attachmentList.map(({ subject, attachment }, index) => (
|
||||
<a key={`${attachment.id ?? index}`} className="btn btn-outline-primary w-100 text-start mb-2" href={attachment.download_url ?? '#'} target="_blank" rel="noreferrer">
|
||||
{`${subject} • ${attachment.name ?? 'Attachment'}`}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminStudentScoreCardPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<Array<Record<string, unknown>>>([])
|
||||
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
|
||||
const [selectedStudent, setSelectedStudent] = useState<{ firstname?: string | null; lastname?: string | null; school_id?: string | null } | null>(null)
|
||||
const [rows, setRows] = useState<StudentScoreCardRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function runSearch(e?: FormEvent) {
|
||||
e?.preventDefault()
|
||||
if (!query.trim()) return
|
||||
try {
|
||||
const data: AdministratorDashboardSearchResponse = await searchAdministratorDashboard(query.trim())
|
||||
setResults(data.results?.students ?? [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to search students.')
|
||||
}
|
||||
}
|
||||
|
||||
async function openScoreCard(studentId: number) {
|
||||
try {
|
||||
const data = await fetchStudentScoreCard(studentId)
|
||||
setSelectedStudentId(studentId)
|
||||
setSelectedStudent(data.student)
|
||||
setRows(data.rows ?? [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load student score card.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title="Student Score Card (Admin)">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-body">
|
||||
<form onSubmit={(e) => void runSearch(e)}>
|
||||
<div className="row g-2 justify-content-center">
|
||||
<div className="col-12 col-md-8 col-lg-6">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">Search Student</span>
|
||||
<input className="form-control" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Type School ID or Student Name" />
|
||||
<button className="btn btn-outline-secondary" type="button" onClick={() => setQuery('')}>Clear</button>
|
||||
</div>
|
||||
<small className="text-muted">Searches all grades.</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="d-none">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.trim() && results.length === 0 ? <div className="alert alert-warning">No students found.</div> : null}
|
||||
{results.length > 0 ? (
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header d-flex justify-content-between align-items-center"><strong>Results</strong><span className="text-muted small">Showing matching students</span></div>
|
||||
<div className="card-body table-responsive pt-2">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead className="table-light"><tr><th>School ID</th><th>Student</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{results.map((row) => (
|
||||
<tr key={String(row.id ?? '')}>
|
||||
<td>{String(row.school_id ?? '')}</td>
|
||||
<td>{`${String(row.firstname ?? '')} ${String(row.lastname ?? '')}`.trim()}</td>
|
||||
<td className="text-end">
|
||||
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void openScoreCard(Number(row.id))}>Open Score Card</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedStudentId && selectedStudent ? (
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">
|
||||
<strong>{`${selectedStudent.firstname ?? ''} ${selectedStudent.lastname ?? ''}`.trim()}</strong>
|
||||
<span className="text-muted ms-2">{selectedStudent.school_id ?? ''}</span>
|
||||
</div>
|
||||
<div className="card-body table-responsive">
|
||||
<table className="table table-bordered table-sm align-middle mb-0">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>School Year</th>
|
||||
<th>Semester</th>
|
||||
<th>Class</th>
|
||||
<th>Homework</th>
|
||||
<th>Project</th>
|
||||
<th>Participation</th>
|
||||
<th>Quiz</th>
|
||||
<th>Test</th>
|
||||
<th>Attendance</th>
|
||||
<th>PTAP</th>
|
||||
<th>Midterm</th>
|
||||
<th>Semester</th>
|
||||
<th>Year Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={13} className="text-muted">No score rows found.</td></tr> : rows.map((row, index) => (
|
||||
<tr key={`${row.school_year}-${row.semester}-${index}`}>
|
||||
<td>{row.school_year ?? '—'}</td>
|
||||
<td>{row.semester ?? '—'}</td>
|
||||
<td>{row.class_section_name ?? '—'}</td>
|
||||
<td>{row.homework_avg ?? '—'}</td>
|
||||
<td>{row.project_avg ?? '—'}</td>
|
||||
<td>{row.participation_score ?? '—'}</td>
|
||||
<td>{row.quiz_avg ?? '—'}</td>
|
||||
<td>{row.test_avg ?? '—'}</td>
|
||||
<td>{row.attendance_score ?? '—'}</td>
|
||||
<td>{row.ptap_score ?? '—'}</td>
|
||||
<td>{row.midterm_exam_score ?? '—'}</td>
|
||||
<td>{row.semester_score ?? '—'}</td>
|
||||
<td>{row.year_score ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminEmergencyContactIndexPage() {
|
||||
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -530,25 +56,53 @@ export function AdminEmergencyContactIndexPage() {
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-striped align-middle">
|
||||
<thead className="table-dark">
|
||||
<tr><th>Parent Name</th><th>Students</th><th>Contact Name</th><th>Relation</th><th>Phone</th><th>Actions</th></tr>
|
||||
<tr>
|
||||
<th>Parent Name</th>
|
||||
<th>Students</th>
|
||||
<th>Contact Name</th>
|
||||
<th>Relation</th>
|
||||
<th>Phone</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.flatMap((group) =>
|
||||
(group.contacts ?? []).map((contact) => (
|
||||
<tr key={contact.id}>
|
||||
<td>{group.parent_name ?? '—'}</td>
|
||||
<td>{(group.students ?? []).map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()).join(', ') || '—'}</td>
|
||||
<td>
|
||||
{(group.students ?? [])
|
||||
.map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim())
|
||||
.join(', ') || '—'}
|
||||
</td>
|
||||
<td>{contact.name ?? '—'}</td>
|
||||
<td>{contact.relation ?? '—'}</td>
|
||||
<td>{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'}</td>
|
||||
<td className="d-flex gap-2">
|
||||
<Link className="btn btn-sm btn-primary" to={`/app/administrator/emergency-contacts/${contact.id}/edit?parent_id=${group.parent_id}`}>Edit</Link>
|
||||
<button className="btn btn-sm btn-danger" type="button" onClick={() => void removeContact(contact.id, group.parent_id)}>Delete</button>
|
||||
<Link
|
||||
className="btn btn-sm btn-primary"
|
||||
to={`/app/administrator/emergency-contacts/${contact.id}/edit?parent_id=${group.parent_id}`}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
onClick={() => void removeContact(contact.id, group.parent_id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)),
|
||||
)}
|
||||
{groups.length === 0 ? <tr><td colSpan={6} className="text-muted">No emergency contacts found.</td></tr> : null}
|
||||
{groups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-muted">
|
||||
No emergency contacts found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -609,13 +163,48 @@ export function AdminEmergencyContactEditPage() {
|
||||
<div className="card shadow-sm" style={{ maxWidth: 720 }}>
|
||||
<div className="card-body">
|
||||
<form onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="mb-3"><label className="form-label">Name</label><input className="form-control" value={form.name} onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))} required /></div>
|
||||
<div className="mb-3"><label className="form-label">Phone</label><input className="form-control" value={form.cellphone} onChange={(e) => setForm((current) => ({ ...current, cellphone: e.target.value }))} required /></div>
|
||||
<div className="mb-3"><label className="form-label">Email</label><input type="email" className="form-control" value={form.email} onChange={(e) => setForm((current) => ({ ...current, email: e.target.value }))} /></div>
|
||||
<div className="mb-3"><label className="form-label">Relation</label><input className="form-control" value={form.relation} onChange={(e) => setForm((current) => ({ ...current, relation: e.target.value }))} /></div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Phone</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.cellphone}
|
||||
onChange={(e) => setForm((current) => ({ ...current, cellphone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((current) => ({ ...current, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Relation</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.relation}
|
||||
onChange={(e) => setForm((current) => ({ ...current, relation: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button className="btn btn-primary" type="submit">Update</button>
|
||||
<Link className="btn btn-secondary" to="/app/administrator/emergency-contacts">Cancel</Link>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Update
|
||||
</button>
|
||||
<Link className="btn btn-secondary" to="/app/administrator/emergency-contacts">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../api/http'
|
||||
import {
|
||||
assignTeacherClass,
|
||||
assignStudentClass,
|
||||
@@ -769,7 +770,7 @@ function CalendarEventForm({
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2 justify-content-end">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/app/administrator/calendar_view')}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/app/administrator/events')}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" type="submit" disabled={submitting}>
|
||||
@@ -817,7 +818,7 @@ export function AdminCalendarAddEventPage() {
|
||||
submitting={false}
|
||||
onSubmit={async (payload) => {
|
||||
await createAdministratorCalendarEvent(payload as never)
|
||||
navigate('/app/administrator/calendar_view')
|
||||
navigate('/app/administrator/events')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -867,12 +868,14 @@ export function AdminCalendarEditPage() {
|
||||
submitting={false}
|
||||
onSubmit={async (payload) => {
|
||||
await updateAdministratorCalendarEvent(Number(eventId), payload)
|
||||
navigate('/app/administrator/calendar_view')
|
||||
navigate('/app/administrator/events')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** School calendar admin — `GET /api/v1/settings/school-calendar/events` (JWT). SPA: `/app/administrator/events` or `calendar_view`. */
|
||||
|
||||
export function AdminCalendarViewPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -909,10 +912,13 @@ export function AdminCalendarViewPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminParityShell title="School Calendar">
|
||||
<AdminParityShell title="Event list">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="d-flex gap-2 mb-3 justify-content-end">
|
||||
<div className="d-flex gap-2 mb-3 flex-wrap justify-content-between align-items-center">
|
||||
<Link to="/app/administrator/calendar" className="btn btn-outline-secondary">
|
||||
Browse calendar
|
||||
</Link>
|
||||
<Link to={`/app/administrator/calendar_add_event${schoolYear || semester ? `?${new URLSearchParams({ ...(schoolYear ? { school_year: schoolYear } : {}), ...(semester ? { semester } : {}) }).toString()}` : ''}`} className="btn btn-success">
|
||||
Add New Event
|
||||
</Link>
|
||||
@@ -1040,10 +1046,10 @@ export function AdminCalendarPage() {
|
||||
}, [events])
|
||||
|
||||
return (
|
||||
<AdminParityShell title="School Calendar">
|
||||
<AdminParityShell title="School calendar">
|
||||
<div className="d-flex justify-content-end mb-3">
|
||||
<Link to="/app/administrator/calendar_view" className="btn btn-outline-secondary">
|
||||
Manage Events
|
||||
<Link to="/app/administrator/events" className="btn btn-outline-secondary">
|
||||
Manage events (list)
|
||||
</Link>
|
||||
</div>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
@@ -2314,7 +2320,7 @@ export function AdminParentProfilePage() {
|
||||
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load parent profiles.')
|
||||
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profiles.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -2323,8 +2329,13 @@ export function AdminParentProfilePage() {
|
||||
|
||||
async function loadGuardian(guardianId: number) {
|
||||
setSelectedGuardianId(guardianId)
|
||||
const data = await fetchFamilyAdminIndex({ guardianId })
|
||||
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchFamilyAdminIndex({ guardianId })
|
||||
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load families.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -2622,6 +2633,106 @@ export function AdminSearchResultsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */
|
||||
export function AdminSectionsPromotionTotalsPage() {
|
||||
const [schoolYear, setSchoolYear] = useState('')
|
||||
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function load(year = schoolYear) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchStudentPromotionTotals(year || undefined)
|
||||
setRows(data.rows ?? [])
|
||||
setSchoolYear(data.year ?? year ?? '')
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load promotion totals.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AdminParityShell title="Section promotion totals">
|
||||
<p className="text-muted small mb-3">
|
||||
Per-class/section student counts from the promotion pipeline. Pair with{' '}
|
||||
<Link to="/app/administrator/sections/auto-distribute">auto-distribute sections</Link> to create
|
||||
sections from these totals.
|
||||
</p>
|
||||
|
||||
<div className="row g-2 align-items-end mb-3 flex-wrap">
|
||||
<div className="col-sm-4 col-lg-3">
|
||||
<label className="form-label">School year</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={schoolYear}
|
||||
onChange={(e) => setSchoolYear(e.target.value)}
|
||||
placeholder="e.g. 2025-2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/sections/auto-distribute">
|
||||
Auto-distribute sections
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Class / section</th>
|
||||
<th className="text-end">Total students</th>
|
||||
<th className="text-muted small">class_id</th>
|
||||
<th className="text-muted small">class_section_id</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-muted">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-muted">
|
||||
No promotion totals for this school year.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, i) => {
|
||||
const key = String(row.class_section_id ?? row.class_id ?? i)
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{row.class_section_name ?? '—'}</td>
|
||||
<td className="text-end">{Number(row.total ?? 0)}</td>
|
||||
<td className="text-muted small">{row.class_id ?? '—'}</td>
|
||||
<td className="text-muted small">{row.class_section_id ?? '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminSectionsAutoDistributePage() {
|
||||
const [schoolYear, setSchoolYear] = useState('')
|
||||
const [studentsPerSection, setStudentsPerSection] = useState(20)
|
||||
@@ -2990,12 +3101,23 @@ export function AdminSubjectCurriculumPage() {
|
||||
const [form, setForm] = useState({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function load() {
|
||||
const data = await fetchSubjectCurriculum()
|
||||
setClasses(data.classes ?? [])
|
||||
setEntries(data.entries ?? [])
|
||||
setSubjects(data.subject_labels ?? {})
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchSubjectCurriculum()
|
||||
setClasses(data.classes ?? [])
|
||||
setEntries(data.entries ?? [])
|
||||
setSubjects(data.subject_labels ?? {})
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3004,6 +3126,8 @@ export function AdminSubjectCurriculumPage() {
|
||||
|
||||
async function submitForm(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const payload = {
|
||||
class_id: Number(form.class_id),
|
||||
subject: form.subject,
|
||||
@@ -3011,27 +3135,49 @@ export function AdminSubjectCurriculumPage() {
|
||||
unit_title: form.unit_title || null,
|
||||
chapter_name: form.chapter_name,
|
||||
}
|
||||
if (editingId) {
|
||||
await updateSubjectCurriculum(editingId, payload)
|
||||
setMessage('Curriculum entry updated.')
|
||||
} else {
|
||||
await createSubjectCurriculum(payload)
|
||||
setMessage('Curriculum entry created.')
|
||||
try {
|
||||
if (editingId) {
|
||||
await updateSubjectCurriculum(editingId, payload)
|
||||
setMessage('Curriculum entry updated.')
|
||||
} else {
|
||||
await createSubjectCurriculum(payload)
|
||||
setMessage('Curriculum entry created.')
|
||||
}
|
||||
setEditingId(null)
|
||||
setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
||||
await load()
|
||||
} catch (e: unknown) {
|
||||
setMessage(null)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to save curriculum entry.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEntry(id: number) {
|
||||
if (!confirm('Delete this curriculum entry?')) return
|
||||
setError(null)
|
||||
try {
|
||||
await deleteSubjectCurriculum(id)
|
||||
setMessage('Curriculum entry deleted.')
|
||||
await load()
|
||||
} catch (e: unknown) {
|
||||
setMessage(null)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to delete curriculum entry.')
|
||||
}
|
||||
setEditingId(null)
|
||||
setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
||||
await load()
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminParityShell title="Subject Curriculum">
|
||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<div className="row g-4">
|
||||
<div className="col-lg-5">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">{editingId ? 'Edit curriculum entry' : 'Add new curriculum entry'}</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={(e) => void submitForm(e)}>
|
||||
<fieldset disabled={loading || saving} className="border-0 m-0 p-0">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Class</label>
|
||||
<select className="form-select" value={form.class_id} onChange={(e) => setForm((current) => ({ ...current, class_id: e.target.value }))} required>
|
||||
@@ -3049,9 +3195,28 @@ export function AdminSubjectCurriculumPage() {
|
||||
<div className="mb-3"><label className="form-label">Unit title</label><input className="form-control" value={form.unit_title} onChange={(e) => setForm((current) => ({ ...current, unit_title: e.target.value }))} /></div>
|
||||
<div className="mb-3"><label className="form-label">Chapter / Surah</label><input className="form-control" value={form.chapter_name} onChange={(e) => setForm((current) => ({ ...current, chapter_name: e.target.value }))} required /></div>
|
||||
<div className="d-flex gap-2">
|
||||
<button className="btn btn-primary flex-grow-1" type="submit">{editingId ? 'Apply changes' : 'Save curriculum entry'}</button>
|
||||
{editingId ? <button className="btn btn-outline-secondary" type="button" onClick={() => { setEditingId(null); setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' }) }}>Cancel</button> : null}
|
||||
<button
|
||||
className="btn btn-primary flex-grow-1"
|
||||
type="submit"
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : editingId ? 'Apply changes' : 'Save curriculum entry'}
|
||||
</button>
|
||||
{editingId ? (
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
type="button"
|
||||
disabled={loading || saving}
|
||||
onClick={() => {
|
||||
setEditingId(null)
|
||||
setForm({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3063,19 +3228,56 @@ export function AdminSubjectCurriculumPage() {
|
||||
<table className="table table-bordered table-hover align-middle mb-0">
|
||||
<thead className="table-light"><tr><th>Class</th><th>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{entries.length === 0 ? <tr><td colSpan={6} className="text-muted">No curriculum records yet.</td></tr> : entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.class_name ?? '—'}</td>
|
||||
<td>{subjects[entry.subject] ?? entry.subject}</td>
|
||||
<td>{entry.unit_number ?? '—'}</td>
|
||||
<td>{entry.unit_title ?? '—'}</td>
|
||||
<td>{entry.chapter_name}</td>
|
||||
<td className="d-flex gap-2">
|
||||
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => { setEditingId(entry.id); setForm({ class_id: String(entry.class_id), subject: entry.subject, unit_number: entry.unit_number ? String(entry.unit_number) : '', unit_title: entry.unit_title ?? '', chapter_name: entry.chapter_name }) }}>Edit</button>
|
||||
<button className="btn btn-sm btn-outline-danger" type="button" onClick={() => void deleteSubjectCurriculum(entry.id).then(load)}>Delete</button>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-muted">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-muted">
|
||||
No curriculum records yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.class_name ?? '—'}</td>
|
||||
<td>{subjects[entry.subject] ?? entry.subject}</td>
|
||||
<td>{entry.unit_number ?? '—'}</td>
|
||||
<td>{entry.unit_title ?? '—'}</td>
|
||||
<td>{entry.chapter_name}</td>
|
||||
<td className="d-flex gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setEditingId(entry.id)
|
||||
setForm({
|
||||
class_id: String(entry.class_id),
|
||||
subject: entry.subject,
|
||||
unit_number: entry.unit_number ? String(entry.unit_number) : '',
|
||||
unit_title: entry.unit_title ?? '',
|
||||
chapter_name: entry.chapter_name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void removeEntry(entry.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -3183,24 +3385,35 @@ export function AdminTeacherClassAssignmentPage() {
|
||||
}
|
||||
|
||||
export function AdminTeacherSubmissionsPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
|
||||
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [semester, setSemester] = useState<string | null>(null)
|
||||
const [schoolYear, setSchoolYear] = useState<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
const data = await fetchTeacherSubmissions()
|
||||
setRows(data.rows ?? [])
|
||||
setSummary(data.summary ?? {})
|
||||
setSemester(data.semester ?? null)
|
||||
setSchoolYear(data.schoolYear ?? null)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchTeacherSubmissions(searchParams)
|
||||
setRows(data.rows ?? [])
|
||||
setSummary(data.summary ?? {})
|
||||
setSemester(data.semester ?? null)
|
||||
setSchoolYear(data.schoolYear ?? null)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load teacher submissions.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
}, [searchParams])
|
||||
|
||||
async function sendNotifications() {
|
||||
const notify = Object.entries(selected).filter(([, checked]) => checked).map(([id]) => Number(id))
|
||||
@@ -3210,13 +3423,20 @@ export function AdminTeacherSubmissionsPage() {
|
||||
(row.teachers ?? []).map((teacher) => [String(teacher.id), row.missing_items ?? []]),
|
||||
),
|
||||
)
|
||||
const result = await notifyTeacherSubmissions({ notify, missing_items: missingItems })
|
||||
setMessage(result.message ?? 'Notifications sent.')
|
||||
try {
|
||||
const result = await notifyTeacherSubmissions({ notify, missing_items: missingItems })
|
||||
setMessage(result.message ?? 'Notifications sent.')
|
||||
setError(null)
|
||||
} catch (e: unknown) {
|
||||
setMessage(null)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to send notifications.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminParityShell title="Teacher Submission Dashboard">
|
||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Completion</div><div className="fs-3 fw-semibold">{summary.submission_percentage ?? 0}%</div></div></div></div>
|
||||
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Missing Items</div><div className="fs-3 fw-semibold">{summary.missing_items ?? 0}</div></div></div></div>
|
||||
@@ -3224,15 +3444,31 @@ export function AdminTeacherSubmissionsPage() {
|
||||
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Term</div><div className="fs-6 fw-semibold">{`${semester ?? '—'} ${schoolYear ?? ''}`.trim()}</div></div></div></div>
|
||||
</div>
|
||||
<div className="d-flex justify-content-end mb-3">
|
||||
<button className="btn btn-primary" type="button" onClick={() => void sendNotifications()}>Notify Selected Teachers</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => void sendNotifications()}
|
||||
>
|
||||
Notify Selected Teachers
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
<table className="table table-striped table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr><th>Notify</th><th>Class-Section</th><th>Teachers</th><th>Midterm Score</th><th>Midterm Comment</th><th>Participation</th><th>PTAP Comment</th><th>Attendance</th><th>Missing Items</th><th>Student Count</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={10} className="text-muted">No teacher-class assignments found for this term.</td></tr> : rows.map((row) => (
|
||||
{!loading && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="text-muted">
|
||||
No teacher-class assignments found for this term.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!loading
|
||||
? rows.map((row) => (
|
||||
<tr key={String(row.class_section_id ?? row.class_section ?? '')}>
|
||||
<td>
|
||||
<div className="d-flex flex-column gap-1">
|
||||
@@ -3254,7 +3490,8 @@ export function AdminTeacherSubmissionsPage() {
|
||||
<td>{(row.missing_items ?? []).join(', ') || '—'}</td>
|
||||
<td>{row.student_count ?? 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -3310,7 +3547,7 @@ export function AdminCourseMaterialsPage() {
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/class_section">
|
||||
Class Sections
|
||||
</Link>
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/calendar_view">
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/events">
|
||||
Calendar Events
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react'
|
||||
import { importLegacySecondParents } from '../api/session'
|
||||
|
||||
type ImportSummary = {
|
||||
created_users?: number
|
||||
guardians_linked?: number
|
||||
skipped?: number
|
||||
total_source?: number
|
||||
}
|
||||
|
||||
export function FamiliesImportLegacyPage() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [summary, setSummary] = useState<ImportSummary | null>(null)
|
||||
|
||||
async function runImport() {
|
||||
const confirmed = window.confirm(
|
||||
'Run the legacy second-parent import now? This will create users and attach guardians based on legacy records.',
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const res = await importLegacySecondParents()
|
||||
setSummary(res.data ?? null)
|
||||
setMessage(res.message ?? 'Legacy import completed.')
|
||||
} catch (e) {
|
||||
setSummary(null)
|
||||
setError(e instanceof Error ? e.message : 'Legacy import failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<div className="mb-4">
|
||||
<h2 className="h4 mb-1">Import Legacy Families</h2>
|
||||
<p className="text-muted mb-0">
|
||||
Import second parents from the legacy <code>parents</code> table into the new family
|
||||
structure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-xl-7">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">Action</div>
|
||||
<div className="card-body">
|
||||
<div className="alert alert-warning small mb-3">
|
||||
This operation can create secondary user accounts, link guardians to families, and
|
||||
backfill student-family associations. Run it intentionally.
|
||||
</div>
|
||||
|
||||
<ul className="small text-muted mb-4">
|
||||
<li>Creates users for email-only second parents when no user already exists.</li>
|
||||
<li>Links imported guardians into the family records for the primary parent.</li>
|
||||
<li>Ensures students for the primary parent are attached to the same family.</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void runImport()}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? 'Importing…' : 'Run Legacy Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-xl-5">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">Latest Result</div>
|
||||
<div className="card-body">
|
||||
{error ? <div className="alert alert-danger small">{error}</div> : null}
|
||||
{message ? <div className="alert alert-success small">{message}</div> : null}
|
||||
|
||||
{summary ? (
|
||||
<dl className="row mb-0">
|
||||
<dt className="col-7">Source rows</dt>
|
||||
<dd className="col-5 text-end">{summary.total_source ?? 0}</dd>
|
||||
|
||||
<dt className="col-7">Users created</dt>
|
||||
<dd className="col-5 text-end">{summary.created_users ?? 0}</dd>
|
||||
|
||||
<dt className="col-7">Guardians linked</dt>
|
||||
<dd className="col-5 text-end">{summary.guardians_linked ?? 0}</dd>
|
||||
|
||||
<dt className="col-7">Skipped rows</dt>
|
||||
<dd className="col-5 text-end">{summary.skipped ?? 0}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-muted mb-0 small">
|
||||
No import has been run in this session yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,145 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { requestForgotPassword } from '../api/passwordFlow'
|
||||
|
||||
/** Mirrors CodeIgniter `Views/user/forgot_password.php` shell; reset flow remains on the Laravel site. */
|
||||
/** Mirrors CodeIgniter `user/forgot_password.php` + confirmation modal from `password_reset_confirmation.php`. */
|
||||
export function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirmEmail, setConfirmEmail] = useState<string | null>(null)
|
||||
const confirmModalRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmEmail) return
|
||||
const el = confirmModalRef.current
|
||||
const B = (
|
||||
window as unknown as {
|
||||
bootstrap?: { Modal: { getOrCreateInstance: (n: HTMLElement) => { show: () => void } } }
|
||||
}
|
||||
).bootstrap
|
||||
if (el && B?.Modal?.getOrCreateInstance) {
|
||||
B.Modal.getOrCreateInstance(el).show()
|
||||
}
|
||||
}, [confirmEmail])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await requestForgotPassword(email)
|
||||
setConfirmEmail(email.trim())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Request failed.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ci-auth-bg flex-grow-1 py-4">
|
||||
<div className="registration-form ci-parity container mt-5 mb-5 text-center">
|
||||
<div className="text-center mb-4">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/alrahma_logo.png"
|
||||
alt=""
|
||||
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||
onError={(ev) => {
|
||||
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
<div className="registration-form ci-parity container mt-5 mb-5">
|
||||
<form
|
||||
className="text-start mx-auto"
|
||||
style={{ maxWidth: 600 }}
|
||||
onSubmit={(e) => void onSubmit(e)}
|
||||
noValidate
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/alrahma_logo.png"
|
||||
alt=""
|
||||
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||
onError={(ev) => {
|
||||
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h3 className="text-center text-success mb-4" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
Reset Your Password
|
||||
</h3>
|
||||
|
||||
<div className="centered-paragraph mb-4 text-center text-muted">
|
||||
<p className="mb-1">Please enter the email address you used to register with us.</p>
|
||||
<p className="mb-0">We'll send you a link to reset your password.</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger text-center" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="form-group mb-3">
|
||||
<label className="form-label visually-hidden" htmlFor="forgot-email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control item"
|
||||
id="forgot-email"
|
||||
name="email"
|
||||
maxLength={50}
|
||||
placeholder="Email Address"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-center text-success mb-4" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
Reset Your Password
|
||||
</h3>
|
||||
<div className="d-grid mb-3">
|
||||
<button type="submit" className="btn btn-success item" disabled={loading}>
|
||||
{loading ? 'Sending…' : 'Reset Password'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-4 text-muted" style={{ maxWidth: 560 }}>
|
||||
<p>Password reset requests are handled through the school's main website.</p>
|
||||
<p className="mb-0">
|
||||
If this SPA is only the API client, open the legacy site or contact the office for a
|
||||
reset link.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="mb-0">Don't have an account?</p>
|
||||
<Link className="d-inline-block mt-1" to="/register">
|
||||
Register now
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2" style={{ maxWidth: 400, margin: '0 auto' }}>
|
||||
<Link className="btn btn-success item" to="/login">
|
||||
Back to Sign in
|
||||
</Link>
|
||||
<Link className="btn btn-secondary item" to="/register">
|
||||
Register now
|
||||
</Link>
|
||||
<div
|
||||
className="modal fade"
|
||||
id="emailConfirmModal"
|
||||
tabIndex={-1}
|
||||
ref={confirmModalRef}
|
||||
aria-labelledby="emailConfirmLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div className="modal-content rounded-4 shadow border-0" style={{ maxWidth: 600, margin: 'auto' }}>
|
||||
<div className="modal-body text-center">
|
||||
<img
|
||||
src="/alrahma_logo.png"
|
||||
alt=""
|
||||
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||
onError={(ev) => {
|
||||
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
<h5 className="modal-title text-success" id="emailConfirmLabel">
|
||||
Check Your Email
|
||||
</h5>
|
||||
<p className="lead mt-3 mb-2">
|
||||
A link to reset the password has been sent to this email:
|
||||
<br />
|
||||
<strong>{confirmEmail ?? ''}</strong>
|
||||
</p>
|
||||
<p className="mb-0">Please check your inbox and follow the instructions to reset the password.</p>
|
||||
<Link className="btn btn-success mt-3" to="/login">
|
||||
Return to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/* Ported from CodeIgniter `Views/index.php` — scoped to avoid overriding global layout. */
|
||||
.home-landing {
|
||||
--home-primary: #3a8fd1;
|
||||
--home-secondary: #2c5e8a;
|
||||
--home-light-green: #e8f5e9;
|
||||
font-family:
|
||||
'Heebo',
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
color: #333;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.home-landing .home-landing-brand {
|
||||
box-shadow: 0 2px 10px rgba(53, 52, 52, 0.15);
|
||||
}
|
||||
|
||||
.home-landing .home-landing-brand-inner {
|
||||
min-height: 3.75rem;
|
||||
}
|
||||
|
||||
.home-landing .home-landing-logo-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.home-landing .home-landing-logo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.home-landing .home-landing-brand-title {
|
||||
color: var(--home-secondary);
|
||||
font-weight: 700;
|
||||
font-size: clamp(1.25rem, 2.5vw, 1.75rem);
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-landing .green-title {
|
||||
color: var(--home-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-landing .bg-light-green {
|
||||
background-color: var(--home-light-green);
|
||||
}
|
||||
|
||||
.home-landing .btn-home-cta {
|
||||
background-color: #28a745;
|
||||
border-color: #28a745;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.home-landing .btn-home-cta:hover {
|
||||
background-color: #218838;
|
||||
border-color: #1e7e34;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-item {
|
||||
height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-item img {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption {
|
||||
bottom: 20%;
|
||||
text-align: left;
|
||||
padding: 20px;
|
||||
background: rgba(56, 55, 55, 0.1);
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
left: 10%;
|
||||
right: auto;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption h2 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption p {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.home-landing .home-section-xl {
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.home-landing .home-stats {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.home-landing .home-stats .stats-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.home-landing .home-stats .stat-circle {
|
||||
width: 190px;
|
||||
height: 190px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.home-landing .home-stats .stat-value {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.home-landing .home-stats .stat-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.home-landing .home-stats .stat-title {
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0.25rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.home-landing .home-stats .circle-primary {
|
||||
background: radial-gradient(circle at 30% 30%, #0d6efd, #0044aa);
|
||||
}
|
||||
|
||||
.home-landing .home-stats .circle-success {
|
||||
background: radial-gradient(circle at 30% 30%, #198754, #0c4f2c);
|
||||
}
|
||||
|
||||
.home-landing .home-stats .circle-warning {
|
||||
background: radial-gradient(circle at 30% 30%, #daa544, #a66f00);
|
||||
}
|
||||
|
||||
.home-landing .home-stats .circle-info {
|
||||
background: radial-gradient(circle at 30% 30%, #0dcaf0, #047e9c);
|
||||
}
|
||||
|
||||
.home-landing .home-stats .circle-light {
|
||||
background: radial-gradient(circle at 30% 30%, #63af4c, #00eb7d);
|
||||
}
|
||||
|
||||
.home-landing .row.bg-light.squared {
|
||||
margin-bottom: 0 !important;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.home-landing .row.bg-light.squared .col-lg-6.text-center {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-landing .image-container {
|
||||
margin-right: 30px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0 15px 15px 0;
|
||||
}
|
||||
|
||||
.home-landing .image-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.home-landing .image-container:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.home-landing .row.bg-light.squared .image-container {
|
||||
position: relative !important;
|
||||
margin: 0 auto !important;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.home-landing .image-column {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.home-landing .about-img img {
|
||||
border: 5px solid #fff;
|
||||
box-shadow: 0 0 15px rgba(72, 236, 7, 0.1);
|
||||
}
|
||||
|
||||
.home-landing .back-to-top {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
z-index: 99;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.home-landing .back-to-top.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.home-landing .home-carousel .carousel-item {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption {
|
||||
bottom: 15%;
|
||||
padding: 15px;
|
||||
width: 80%;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.home-landing .image-column {
|
||||
min-height: 300px;
|
||||
border-radius: 0 0 10px 10px !important;
|
||||
}
|
||||
|
||||
.home-landing .rounded-end {
|
||||
border-radius: 0 0 10px 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home-landing .home-carousel .carousel-item {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption {
|
||||
bottom: 10%;
|
||||
width: 85%;
|
||||
left: 7.5%;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption p {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.home-landing .home-stats .stat-circle {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.home-landing .row.bg-light.squared .image-container {
|
||||
max-width: 360px;
|
||||
min-height: 0 !important;
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.home-landing .row.bg-light.squared .overlapping-image {
|
||||
position: relative !important;
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: auto;
|
||||
margin: 15px 0;
|
||||
transform: none !important;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
border: 5px solid white;
|
||||
}
|
||||
|
||||
.home-landing .row.bg-light.squared.position-relative.align-items-center {
|
||||
margin-bottom: 3rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.home-landing .home-carousel .carousel-item {
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption {
|
||||
bottom: 5%;
|
||||
width: 90%;
|
||||
left: 5%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.home-landing .home-carousel .carousel-caption p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
+403
-57
@@ -1,78 +1,424 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchAdministratorDashboardMetrics } from '../api/session'
|
||||
import type { AdministratorDashboardMetricsResponse, AuthUser } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import './HomePage.css'
|
||||
|
||||
/** Public home must not call administrator APIs unless the user is allowed (avoids 401 for teachers/guests). */
|
||||
function userMayLoadAdministratorHomeMetrics(user: AuthUser | null): boolean {
|
||||
if (!user?.roles) return false
|
||||
const r = user.roles
|
||||
return Boolean(r.administrator || r.admin || r.super_admin)
|
||||
}
|
||||
|
||||
/** Carousel + sections ported from CodeIgniter `Views/index.php` (Al Rahma public home). */
|
||||
const CAROUSEL_SLIDES = [
|
||||
{
|
||||
image: '/images/carousel-1.png',
|
||||
alt: 'Not only a book to be read but a guide illuminating our lives.',
|
||||
title: 'Not only a book to be read but a guide illuminating our lives.',
|
||||
body:
|
||||
'At Al Rahma Sunday School, we teach the Quran as a living guide, nurturing love and respect for the words of Allah (SWT). Our program supports each child’s journey through recitation, memorization, and age-appropriate translation and understanding.',
|
||||
},
|
||||
{
|
||||
image: '/images/carousel-0.png',
|
||||
alt: 'Faith and Knowledge Hand in Hand',
|
||||
title: 'Faith and Knowledge Hand in Hand',
|
||||
body:
|
||||
'Our Sunday School program (Grades 1-9, Youth) is evenly divided between Islamic Studies in English—exploring topics about Allah, Islamic values, Muslim life and lessons from the Prophets—and Quran/Arabic studies focused on recitation, memorization and language. This balanced approach nurtures both understanding and practice of Islam.',
|
||||
},
|
||||
{
|
||||
image: '/images/carousel-3.png',
|
||||
alt: 'Nurturing Hearts and Minds',
|
||||
title: 'Nurturing Hearts and Minds',
|
||||
body:
|
||||
'At our school, we inspire a lifelong love for Islamic values, for our worship rituals and our prophets through engaging lessons, interactive activities, and a supportive environment. Every student grows in knowledge, faith and character, learning to live the teachings of Islam in everyday life.',
|
||||
},
|
||||
{
|
||||
image: '/images/carousel-4.png',
|
||||
alt: 'Exciting Fun, Inside and Out!',
|
||||
title: 'Exciting Fun, Inside and Out!',
|
||||
body:
|
||||
'From festive Eid celebrations to adventurous outdoor outings, our school events bring learning, laughter, and unforgettable memories together.',
|
||||
},
|
||||
{
|
||||
image: '/images/carousel-5.png',
|
||||
alt: 'Strengthening Faith, Character, and Community Bonds',
|
||||
title: 'Strengthening Faith, Character, and Community Bonds',
|
||||
body:
|
||||
'A warm, faith-centered community helps children thrive, build confidence, compassion, and friendships.',
|
||||
},
|
||||
] as const
|
||||
|
||||
const STAT_DEF = [
|
||||
{ key: 'students', label: 'Students', circle: 'circle-primary', icon: 'bi-mortarboard' },
|
||||
{ key: 'teachers', label: 'Teachers', circle: 'circle-info', icon: 'bi-person-video3' },
|
||||
{ key: 'teacherAssistants', label: 'TAs', circle: 'circle-success', icon: 'bi-person-gear' },
|
||||
{ key: 'admins', label: 'Admins', circle: 'circle-warning', icon: 'bi-shield-check' },
|
||||
{ key: 'parents', label: 'Parents', circle: 'circle-light', icon: 'bi-people' },
|
||||
] as const
|
||||
|
||||
type CountKey = (typeof STAT_DEF)[number]['key']
|
||||
|
||||
function formatStat(
|
||||
n: number | undefined,
|
||||
numberFmt: Intl.NumberFormat,
|
||||
): string {
|
||||
return typeof n === 'number' && Number.isFinite(n) ? numberFmt.format(n) : '—'
|
||||
}
|
||||
|
||||
/** Public landing — structure inspired by CodeIgniter `Views/landing_page/guest_dashboard.php` hero. */
|
||||
export function HomePage() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { token, user } = useAuth()
|
||||
const numberFmt = useMemo(() => new Intl.NumberFormat(), [])
|
||||
const [counts, setCounts] = useState<AdministratorDashboardMetricsResponse['counts']>({})
|
||||
const [showTop, setShowTop] = useState(false)
|
||||
|
||||
const mayLoadMetrics = Boolean(token && userMayLoadAdministratorHomeMetrics(user))
|
||||
|
||||
useEffect(() => {
|
||||
if (!mayLoadMetrics) {
|
||||
setCounts({})
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const data = await fetchAdministratorDashboardMetrics()
|
||||
if (!cancelled) setCounts(data.counts ?? {})
|
||||
} catch {
|
||||
if (!cancelled) setCounts({})
|
||||
}
|
||||
}
|
||||
void loadStats()
|
||||
const interval = window.setInterval(() => void loadStats(), 60_000)
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') void loadStats()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(interval)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [mayLoadMetrics])
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setShowTop(window.scrollY > 100)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
onScroll()
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
function countFor(key: CountKey): string {
|
||||
return formatStat(counts?.[key], numberFmt)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-grow-1">
|
||||
<section className="hero-section position-relative overflow-hidden bg-white">
|
||||
<div className="welcome-message text-center py-5 px-3">
|
||||
<h1 className="display-5 fw-semibold text-success mb-2">
|
||||
Welcome to Al Rahma Sunday School
|
||||
</h1>
|
||||
<h2 className="h4 text-secondary mb-3">
|
||||
{isAuthenticated ? 'Welcome back' : 'Welcome, Guest!'}
|
||||
</h2>
|
||||
<p className="lead text-muted mx-auto mb-4" style={{ maxWidth: 640 }}>
|
||||
Use the portal to manage enrollment, attendance, grades, and family information — the
|
||||
same workflows as the CodeIgniter site, backed by the Laravel API.
|
||||
</p>
|
||||
<div className="d-flex flex-wrap justify-content-center gap-2">
|
||||
{isAuthenticated ? (
|
||||
<Link className="btn btn-success btn-lg px-4" to="/app/home">
|
||||
Go to dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link className="btn btn-success btn-lg px-4" to="/login">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link className="btn btn-outline-success btn-lg px-4" to="/register">
|
||||
Registration Form
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link className="btn btn-outline-secondary btn-lg" to="/docs">
|
||||
API documentation
|
||||
</Link>
|
||||
</div>
|
||||
<div className="home-landing flex-grow-1">
|
||||
<div
|
||||
id="homeMainCarousel"
|
||||
className="carousel slide carousel-fade home-carousel"
|
||||
data-bs-ride="carousel"
|
||||
>
|
||||
<div className="carousel-indicators">
|
||||
{CAROUSEL_SLIDES.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
data-bs-target="#homeMainCarousel"
|
||||
data-bs-slide-to={i}
|
||||
className={i === 0 ? 'active' : undefined}
|
||||
aria-current={i === 0 ? 'true' : undefined}
|
||||
aria-label={`Slide ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="carousel-inner">
|
||||
{CAROUSEL_SLIDES.map((slide, i) => (
|
||||
<div key={slide.image} className={`carousel-item${i === 0 ? ' active' : ''}`}>
|
||||
<img src={slide.image} className="d-block w-100" alt={slide.alt} />
|
||||
<div className="carousel-caption">
|
||||
<h2 className="mb-3">{slide.title}</h2>
|
||||
<p className="mb-0">{slide.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="carousel-control-prev"
|
||||
type="button"
|
||||
data-bs-target="#homeMainCarousel"
|
||||
data-bs-slide="prev"
|
||||
>
|
||||
<span className="carousel-control-prev-icon" aria-hidden />
|
||||
<span className="visually-hidden">Previous</span>
|
||||
</button>
|
||||
<button
|
||||
className="carousel-control-next"
|
||||
type="button"
|
||||
data-bs-target="#homeMainCarousel"
|
||||
data-bs-slide="next"
|
||||
>
|
||||
<span className="carousel-control-next-icon" aria-hidden />
|
||||
<span className="visually-hidden">Next</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="text-center my-3">
|
||||
<a
|
||||
href="/account_creation_guide.pdf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-decoration-none"
|
||||
style={{ fontWeight: 900, color: '#000' }}
|
||||
>
|
||||
<h3 className="fw-bold my-0">
|
||||
<span className="text-decoration-underline">
|
||||
Join Al Rahma family today! Click here for a quick guide on how to create an account.
|
||||
</span>
|
||||
</h3>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section className="py-5 border-top bg-light">
|
||||
<div className="container-xxl py-3 home-section-xl content-section">
|
||||
<div className="container">
|
||||
<div className="row g-4 justify-content-center">
|
||||
<div className="col-md-4">
|
||||
<div className="card border-0 shadow-sm h-100">
|
||||
<div className="card-body">
|
||||
<h3 className="h6 text-success">
|
||||
<i className="bi bi-house-door me-2" aria-hidden />
|
||||
Families
|
||||
</h3>
|
||||
<p className="small text-muted mb-0">
|
||||
Parents sign in after registration is activated by email.
|
||||
</p>
|
||||
<div className="home-stats">
|
||||
<h2 className="d-flex justify-content-center p-md-1 fs-3 fw-bold green-title mb-0">
|
||||
Active Participants
|
||||
</h2>
|
||||
<br />
|
||||
<div className="stats-row">
|
||||
{STAT_DEF.map((s) => (
|
||||
<div key={s.key} className={`stat-circle ${s.circle}`}>
|
||||
<div className="stat-icon">
|
||||
<i className={`bi ${s.icon}`} aria-hidden />
|
||||
</div>
|
||||
<div className="stat-value">{countFor(s.key)}</div>
|
||||
<div className="stat-title">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container-xxl py-3 home-section-xl content-section">
|
||||
<div className="container">
|
||||
<div className="row bg-light squared align-items-center g-0">
|
||||
<div className="col-lg-6">
|
||||
<div className="d-flex flex-column justify-content-center p-4 p-md-5">
|
||||
<h2 className="mb-4 fs-2 fw-bold green-title">
|
||||
Faith, Education and Community for Over 30 Years
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
The Islamic Society of Greater Lowell (ISGL), established in 1993 in Chelmsford,
|
||||
Massachusetts, serves as both a mosque and a vibrant community center. It provides a
|
||||
place of worship for Allah (SWT), offers Islamic education and facilitates religious
|
||||
and social activities for Muslims in the Merrimack Valley. As a non-profit,
|
||||
charitable, and tax-exempt organization, ISGL also provides essential services such as
|
||||
marriage ceremonies, burial arrangements and community support while promoting a
|
||||
greater understanding of Islam in America.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Under the umbrella of ISGL, Al Rahma Sunday School has been serving the community for
|
||||
over thirty years. Throughout the decades, it has provided students with a strong
|
||||
foundation in Islamic Studies and Quran, fostering both knowledge and character. With
|
||||
its dedicated teachers and well-rounded academic program, the school continues to
|
||||
guide generations of Muslim youth in their faith, values and practice of Islam.
|
||||
</p>
|
||||
<p>For more information, click below to visit ISGL official website.</p>
|
||||
<div>
|
||||
<a
|
||||
className="btn btn-home-cta px-4 py-2 fw-semibold"
|
||||
href="https://isgl.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ISGL Official Website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="card border-0 shadow-sm h-100">
|
||||
<div className="card-body">
|
||||
<h3 className="h6 text-success">
|
||||
<i className="bi bi-shield-lock me-2" aria-hidden />
|
||||
Secure portal
|
||||
</h3>
|
||||
<p className="small text-muted mb-0">
|
||||
Sessions use JWT from the Laravel API (<code>/api/v1/auth/login</code>).
|
||||
</p>
|
||||
<div className="col-lg-6 image-column pe-lg-3">
|
||||
<div className="position-relative h-100 w-100">
|
||||
<img
|
||||
className="w-100 h-100 rounded-end"
|
||||
src="/images/isgl_home.png"
|
||||
alt="ISGL"
|
||||
style={{ objectFit: 'cover', minHeight: 280 }}
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget
|
||||
img.onerror = null
|
||||
img.src = '/images/call-to-action.jpg'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container-xxl py-3 home-section-xl content-section">
|
||||
<div className="container">
|
||||
<div className="row bg-light squared align-items-center g-0">
|
||||
<div className="col-lg-6">
|
||||
<div className="d-flex flex-column justify-content-center p-4 p-md-5">
|
||||
<h2 className="mb-4 fs-2 fw-bold green-title">Our Mission</h2>
|
||||
<p>
|
||||
Al Rahma Sunday School's mission is to make the Masjid a central part of our
|
||||
Muslim children's lives from a very young age. We are dedicated to offering a
|
||||
strong, well-rounded curriculum in Quran, Arabic and Islamic Studies. Our goal is to
|
||||
deliver the highest quality education to help instill Islamic values, beliefs and
|
||||
practices in each student. Additionally, we regularly organize engaging and fun events
|
||||
throughout the year to make the learning experience more enjoyable and spiritually
|
||||
enriching.
|
||||
</p>
|
||||
<p className="mb-0">
|
||||
We deeply appreciate your continued support and cooperation in helping us fulfill this
|
||||
mission. Together, we can provide a strong Islamic foundation for our children and shape
|
||||
a future generation that is confident in their faith and rooted in Islamic values.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-6 about-img py-4 py-lg-0">
|
||||
<div className="row g-2 justify-content-center">
|
||||
<div className="col-12 text-center">
|
||||
<img
|
||||
className="img-fluid w-75 rounded-circle bg-light p-3"
|
||||
src="/images/about-1.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 text-start" style={{ marginTop: '-150px' }}>
|
||||
<img
|
||||
className="img-fluid w-100 rounded-circle bg-light p-3"
|
||||
src="/images/about-2.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 text-end" style={{ marginTop: '-150px' }}>
|
||||
<img
|
||||
className="img-fluid w-100 rounded-circle bg-light p-3"
|
||||
src="/images/about-3.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="container-xxl py-3 home-section-xl content-section">
|
||||
<div className="container">
|
||||
<div className="row bg-light squared align-items-center g-0">
|
||||
<div className="col-lg-6">
|
||||
<div className="d-flex flex-column justify-content-center p-4 p-md-5">
|
||||
<h2 className="mb-4 fs-2 fw-bold green-title">Become A Teacher or Admin</h2>
|
||||
<p className="mb-4">
|
||||
Volunteering at Al Rahma Sunday School is a deeply fulfilling way to give back to the
|
||||
community and help shape the next generation. Whether you're passionate about
|
||||
teaching or prefer working behind the scenes, your time and dedication can make a
|
||||
lasting difference. Join a team committed to nurturing young minds and strengthening
|
||||
their connection to faith.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
As a volunteer teacher or teacher assistant, you'll have the opportunity to
|
||||
inspire, educate, and guide children on their spiritual journey. Teachers play a vital
|
||||
role in helping students understand the Quran, Islamic values, and the Arabic language
|
||||
in a warm and engaging classroom environment. Teacher assistants support instruction,
|
||||
help manage classroom activities, and provide one-on-one assistance to students when
|
||||
needed. Whether leading the class or lending a helping hand, your commitment can
|
||||
spark a lifelong love for learning and faith in the hearts of our students.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Administrative volunteers are essential to the success of our school. From organizing
|
||||
materials and managing communications to helping coordinate school events and
|
||||
logistics, your work ensures a smooth and efficient learning experience for students
|
||||
and teachers alike. If you enjoy structured tasks, planning, or simply being helpful
|
||||
in a quiet but powerful way, this role is perfect for you.
|
||||
</p>
|
||||
<div>
|
||||
<Link className="btn btn-home-cta py-3 px-5 mt-1" to="/register">
|
||||
Get Started Now <i className="bi bi-arrow-right ms-2" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-6 image-column pe-lg-3">
|
||||
<div className="position-relative h-100 w-100">
|
||||
<img
|
||||
className="w-100 h-100 rounded-end"
|
||||
src="/images/call-to-action.jpg"
|
||||
alt=""
|
||||
style={{ objectFit: 'cover', minHeight: 280 }}
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget
|
||||
img.onerror = null
|
||||
img.src = '/images/isgl_home.png'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container-xxl py-3 home-section-xl content-section">
|
||||
<div className="container">
|
||||
<div className="row bg-light squared position-relative align-items-center g-0">
|
||||
<div className="col-lg-6">
|
||||
<div className="h-100 d-flex flex-column justify-content-center p-4 p-md-5">
|
||||
<h2 className="mb-4 fs-2 fw-bold green-title">Our Curriculum and Grade Structure</h2>
|
||||
<p>
|
||||
Our program spans nine structured grade levels, each building upon the previous
|
||||
year's knowledge to ensure a solid foundation in faith, character, and Islamic
|
||||
learning.
|
||||
</p>
|
||||
<p>
|
||||
Upon completing the 9th grade, students transition into our three-year Youth Program,
|
||||
which emphasizes deeper community engagement, personal development and practical
|
||||
application of Islamic principles. Participation in the Youth Program requires students
|
||||
to be at least 15 years old, ensuring they are mature enough to benefit from its
|
||||
advanced content.
|
||||
</p>
|
||||
<p>
|
||||
Starting this academic year, children must be at least 6 years old by 12-31-2025 to
|
||||
enroll in Grade 1. For younger learners, we are pleased to offer our newly established
|
||||
Kindergarten class, welcoming children who are at least 5 years old by 12-31-2025. This
|
||||
early education program is designed to introduce young minds to the basics of Islamic
|
||||
teachings in a warm, age-appropriate environment.
|
||||
</p>
|
||||
<div>
|
||||
<Link className="btn btn-home-cta py-3 px-5 mt-3" to="/register">
|
||||
Get Started Now <i className="bi bi-arrow-right ms-2" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-6 text-start py-4 py-lg-0">
|
||||
<div className="image-container mx-auto mx-lg-0">
|
||||
<img
|
||||
src="/images/12.jpeg"
|
||||
className="overlapping-image image1"
|
||||
alt="Students and activities"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-home-cta btn-lg rounded-1 back-to-top${showTop ? ' visible' : ''}`}
|
||||
aria-label="Back to top"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
<i className="bi bi-arrow-up" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+26
-5
@@ -36,11 +36,19 @@ export function LoginPage() {
|
||||
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'
|
||||
const singleRole = result.roles.length === 1 ? result.roles[0].toLowerCase() : ''
|
||||
|
||||
let target = '/app/home'
|
||||
if (from.startsWith('/app')) target = from
|
||||
else if (from.startsWith('/docs')) target = from
|
||||
|
||||
if (result.roles.length === 1 && target === '/app/home') {
|
||||
if (singleRole === 'parent') target = '/app/parent/home'
|
||||
if (singleRole === 'teacher' || singleRole === 'teacher_assistant') {
|
||||
target = '/app/teacher_dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const dash = await fetchDashboardRoute()
|
||||
const route = dash.data?.dashboard?.route
|
||||
@@ -50,6 +58,19 @@ export function LoginPage() {
|
||||
/* keep default */
|
||||
}
|
||||
|
||||
/* API default dashboard can point parents at teacher URLs (or vice versa); honor single-role account. */
|
||||
if (result.roles.length === 1) {
|
||||
if (singleRole === 'teacher' || singleRole === 'teacher_assistant') {
|
||||
if (target === '/app/home' || target.startsWith('/app/parent')) {
|
||||
target = '/app/teacher_dashboard'
|
||||
}
|
||||
} else if (singleRole === 'parent') {
|
||||
if (target === '/app/home' || target.startsWith('/app/teacher')) {
|
||||
target = '/app/parent/home'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navigate(target, { replace: true })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
|
||||
@@ -133,6 +133,10 @@ export function NavBuilderPage() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const editingItem = form.id
|
||||
? payload.items.find((item) => String(item.id) === form.id) ?? null
|
||||
: null
|
||||
|
||||
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">
|
||||
@@ -240,9 +244,18 @@ export function NavBuilderPage() {
|
||||
<div className="row">
|
||||
<div className="col-lg-5">
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">Add / Edit Item</div>
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<span>{editingItem ? `Edit Item #${editingItem.id}` : 'Add / Edit Item'}</span>
|
||||
{editingItem ? <span className="badge bg-primary">Editing</span> : null}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{editingItem ? (
|
||||
<div className="alert alert-info py-2">
|
||||
Editing <strong>{editingItem.label}</strong>. Save will update the existing menu item.
|
||||
</div>
|
||||
) : null}
|
||||
<form onSubmit={onSubmit}>
|
||||
<input type="hidden" name="id" value={form.id} />
|
||||
<div className="mb-2">
|
||||
<label className="form-label" htmlFor="menu_parent_id">Parent</label>
|
||||
<select
|
||||
@@ -360,10 +373,10 @@ export function NavBuilderPage() {
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? 'Saving...' : editingItem ? 'Update' : 'Save'}
|
||||
</button>{' '}
|
||||
<button className="btn btn-secondary" type="button" onClick={() => setForm(blankForm)}>
|
||||
Reset
|
||||
{editingItem ? 'Cancel edit' : 'Reset'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,79 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchDashboardRoute } from '../api/session'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
|
||||
import { useContinueWithRole } from '../hooks/useContinueWithRole'
|
||||
|
||||
export function RoleSelectPage() {
|
||||
const { user, roles, selectedRole, setSelectedRole } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [busyRole, setBusyRole] = useState<string | null>(null)
|
||||
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(nextRole: string) {
|
||||
setBusy(true)
|
||||
setBusyRole(nextRole)
|
||||
setError(null)
|
||||
setSelectedRole(nextRole)
|
||||
|
||||
let target = from.startsWith('/app') ? from : '/app/home'
|
||||
if (nextRole.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 })
|
||||
}
|
||||
const { user, roles } = useAuth()
|
||||
const { continueWithRole, busy, busyRole, error } = useContinueWithRole()
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="card shadow-sm border-0 mx-auto" style={{ maxWidth: 720 }}>
|
||||
<div className="card-header bg-primary text-white">
|
||||
<h4 className="mb-0">Select Account Type</h4>
|
||||
<div className="registration-form container mt-5 mb-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="text-center mb-4">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/images/alrahma_logo.png"
|
||||
alt="Al Rahma Sunday School"
|
||||
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="mb-4">
|
||||
{user?.name ? `${user.name}, y` : 'Y'}ou have multiple roles. Please select the account
|
||||
you'd like to log into:
|
||||
</p>
|
||||
|
||||
<div className="d-flex flex-wrap gap-3">
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
className={`btn ${
|
||||
selectedRole === role ? 'btn-primary' : 'btn-outline-primary'
|
||||
}`}
|
||||
onClick={() => {
|
||||
void continueLogin(role)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && busyRole === role
|
||||
? 'Opening...'
|
||||
: role.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())}
|
||||
</button>
|
||||
))}
|
||||
<h3 className="text-center text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
Select Your Role to Log In
|
||||
</h3>
|
||||
|
||||
<p className="text-center text-muted mt-2">
|
||||
{user?.name ? `${user.name}, you` : 'You'} have multiple roles. Choose how you want to
|
||||
continue.
|
||||
</p>
|
||||
|
||||
<div className="d-flex flex-wrap justify-content-center gap-3 mt-4">
|
||||
{roles.map((role) => {
|
||||
const label = role.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
return (
|
||||
<div key={role} className="mb-3 d-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-lg btn-success"
|
||||
title={`Log in as ${label}`}
|
||||
onClick={() => {
|
||||
void continueWithRole(role)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && busyRole === role ? 'Opening…' : label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger text-center mt-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger py-2 mt-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
fetchStudentScoreCard,
|
||||
searchAdministratorDashboard,
|
||||
} from '../../api/session'
|
||||
import type { AdministratorDashboardSearchResponse, StudentScoreCardRow } from '../../api/types'
|
||||
|
||||
const MAX_RESULTS = 50
|
||||
|
||||
function buildSuggestionValue(stu: Record<string, unknown>): string {
|
||||
const schoolId = String(stu.school_id ?? '').trim()
|
||||
const label = `${String(stu.firstname ?? '')} ${String(stu.lastname ?? '')}`.trim()
|
||||
return schoolId !== '' ? `${schoolId} - ${label}` : label
|
||||
}
|
||||
|
||||
/** CI `admin/student_score_card.php` — search, datalist, status badges, score table. */
|
||||
export function AdminStudentScoreCardPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const qParam = searchParams.get('q') ?? ''
|
||||
|
||||
const [query, setQuery] = useState(qParam)
|
||||
const [suggestions, setSuggestions] = useState<Array<Record<string, unknown>>>([])
|
||||
const [results, setResults] = useState<Array<Record<string, unknown>>>([])
|
||||
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
|
||||
const [selectedStudent, setSelectedStudent] = useState<{
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
school_id?: string | null
|
||||
} | null>(null)
|
||||
const [rows, setRows] = useState<StudentScoreCardRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const lastAutoQuery = useRef('')
|
||||
|
||||
const suggestionOptions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const stu of suggestions) {
|
||||
const v = buildSuggestionValue(stu).trim()
|
||||
if (!v || seen.has(v)) continue
|
||||
seen.add(v)
|
||||
out.push(v)
|
||||
}
|
||||
return out
|
||||
}, [suggestions])
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(qParam)
|
||||
}, [qParam])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const data: AdministratorDashboardSearchResponse = await searchAdministratorDashboard('')
|
||||
setSuggestions(data.results?.students ?? [])
|
||||
} catch {
|
||||
setSuggestions([])
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!qParam.trim()) return
|
||||
void runSearchQuery(qParam)
|
||||
}, [qParam])
|
||||
|
||||
async function runSearchQuery(q: string) {
|
||||
const trimmed = q.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
const data: AdministratorDashboardSearchResponse = await searchAdministratorDashboard(trimmed)
|
||||
const list = data.results?.students ?? []
|
||||
setResults(list.slice(0, MAX_RESULTS))
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to search students.')
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(e?: FormEvent) {
|
||||
e?.preventDefault()
|
||||
const trimmed = query.trim()
|
||||
setSearchParams(trimmed ? { q: trimmed } : {})
|
||||
await runSearchQuery(trimmed)
|
||||
}
|
||||
|
||||
async function openScoreCard(studentId: number) {
|
||||
try {
|
||||
const data = await fetchStudentScoreCard(studentId)
|
||||
setSelectedStudentId(studentId)
|
||||
setSelectedStudent(data.student)
|
||||
setRows(data.rows ?? [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load student score card.')
|
||||
}
|
||||
}
|
||||
|
||||
function tryAutoRun(currentValue: string) {
|
||||
const val = currentValue.trim()
|
||||
if (!val) return
|
||||
if (suggestionOptions.includes(val) && val !== lastAutoQuery.current) {
|
||||
lastAutoQuery.current = val
|
||||
void runSearchQuery(val)
|
||||
setSearchParams({ q: val })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<style>{`
|
||||
.admin-score-table { margin-top: 4px; }
|
||||
.admin-score-table thead th { position: static; }
|
||||
`}</style>
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<div>
|
||||
<h2 className="mb-0">Student Score Card (Admin)</h2>
|
||||
<div className="text-muted small">Search for a student, then open their score card.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-body">
|
||||
<form
|
||||
id="studentSearchForm"
|
||||
onSubmit={(e) => void runSearch(e)}
|
||||
>
|
||||
<div className="row g-2 justify-content-center">
|
||||
<div className="col-12 col-md-8 col-lg-6">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">Search Student</span>
|
||||
<input
|
||||
id="globalStudentSearch"
|
||||
className="form-control"
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Type School ID or Student Name"
|
||||
list="studentSuggestions"
|
||||
autoComplete="off"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onInput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
window.setTimeout(() => tryAutoRun(v), 50)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
id="globalStudentSearchClear"
|
||||
className="btn btn-outline-secondary"
|
||||
type="button"
|
||||
title="Clear search"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
lastAutoQuery.current = ''
|
||||
setSearchParams({})
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<small className="text-muted">Searches all grades.</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="d-none">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<datalist id="studentSuggestions">
|
||||
{suggestionOptions.map((v) => (
|
||||
<option key={v} value={v} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{query.trim() && results.length === 0 ? (
|
||||
<div className="alert alert-warning">No students found.</div>
|
||||
) : null}
|
||||
|
||||
{results.length > 0 ? (
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Results</strong>
|
||||
<span className="text-muted small">Showing up to {MAX_RESULTS}</span>
|
||||
</div>
|
||||
<div className="card-body table-responsive pt-2">
|
||||
<table className="table table-striped table-hover align-middle admin-score-table no-mgmt-sticky" data-no-mgmt-sticky="1">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>School ID</th>
|
||||
<th>Student</th>
|
||||
<th>Status</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((s) => (
|
||||
<tr key={String(s.id ?? '')}>
|
||||
<td>{String(s.school_id ?? '')}</td>
|
||||
<td>{`${String(s.firstname ?? '')} ${String(s.lastname ?? '')}`.trim()}</td>
|
||||
<td>
|
||||
{Number(s.is_active ?? 0) === 1 ? (
|
||||
<span className="badge text-bg-success">Active</span>
|
||||
) : (
|
||||
<span className="badge text-bg-secondary">Inactive</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
type="button"
|
||||
onClick={() => void openScoreCard(Number(s.id))}
|
||||
>
|
||||
Open Score Card
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedStudentId && selectedStudent ? (
|
||||
<div className="card shadow-sm mt-4">
|
||||
<div className="card-header">
|
||||
<strong>{`${selectedStudent.firstname ?? ''} ${selectedStudent.lastname ?? ''}`.trim()}</strong>
|
||||
<span className="text-muted ms-2">{selectedStudent.school_id ?? ''}</span>
|
||||
</div>
|
||||
<div className="card-body table-responsive">
|
||||
<table className="table table-bordered table-sm align-middle mb-0">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>School Year</th>
|
||||
<th>Semester</th>
|
||||
<th>Class</th>
|
||||
<th>Homework</th>
|
||||
<th>Project</th>
|
||||
<th>Participation</th>
|
||||
<th>Quiz</th>
|
||||
<th>Test</th>
|
||||
<th>Attendance</th>
|
||||
<th>PTAP</th>
|
||||
<th>Midterm</th>
|
||||
<th>Semester</th>
|
||||
<th>Year Avg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={13} className="text-muted">
|
||||
No score rows found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<tr key={`${row.school_year}-${row.semester}-${index}`}>
|
||||
<td>{row.school_year ?? '—'}</td>
|
||||
<td>{row.semester ?? '—'}</td>
|
||||
<td>{row.class_section_name ?? '—'}</td>
|
||||
<td>{row.homework_avg ?? '—'}</td>
|
||||
<td>{row.project_avg ?? '—'}</td>
|
||||
<td>{row.participation_score ?? '—'}</td>
|
||||
<td>{row.quiz_avg ?? '—'}</td>
|
||||
<td>{row.test_avg ?? '—'}</td>
|
||||
<td>{row.attendance_score ?? '—'}</td>
|
||||
<td>{row.ptap_score ?? '—'}</td>
|
||||
<td>{row.midterm_exam_score ?? '—'}</td>
|
||||
<td>{row.semester_score ?? '—'}</td>
|
||||
<td>{row.year_score ?? '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
fetchAdministratorAbsenceForm,
|
||||
submitAdministratorAbsence,
|
||||
} from '../../api/session'
|
||||
import type { AdministratorAbsenceFormResponse } from '../../api/types'
|
||||
|
||||
const DEFAULT_REASON_TYPES: Record<string, string> = {
|
||||
sick: 'Illness / medical',
|
||||
vacation: 'Vacation',
|
||||
personal: 'Personal',
|
||||
professional: 'Professional development',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
function formatYmd(d: string): string {
|
||||
const t = String(d).trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}/.test(t)) return t
|
||||
const date = new Date(t.slice(0, 10) + 'T12:00:00')
|
||||
if (Number.isNaN(date.getTime())) return t
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeFormPayload(body: AdministratorAbsenceFormResponse): {
|
||||
adminName: string
|
||||
semester: string
|
||||
schoolYear: string
|
||||
existing: NonNullable<AdministratorAbsenceFormResponse['existing']>
|
||||
available: string[]
|
||||
reasonTypes: Record<string, string>
|
||||
} {
|
||||
const available =
|
||||
body.availableDates?.length ? body.availableDates : body.available_dates ?? []
|
||||
const schoolYear = body.schoolYear ?? body.school_year ?? ''
|
||||
const reasonTypes =
|
||||
body.reason_types && Object.keys(body.reason_types).length > 0
|
||||
? body.reason_types
|
||||
: DEFAULT_REASON_TYPES
|
||||
return {
|
||||
adminName: String(body.admin_name ?? '').trim() || 'Administrator',
|
||||
semester: String(body.semester ?? '').trim(),
|
||||
schoolYear: String(schoolYear).trim(),
|
||||
existing: body.existing ?? [],
|
||||
available: [...new Set(available.map((d) => String(d).slice(0, 10)))].sort(),
|
||||
reasonTypes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Administrator absence requests — `GET`/`POST /api/v1/administrator/absence` (JWT Bearer).
|
||||
* Pair with Laravel `routes/api.php` + OpenAPI docs.
|
||||
*/
|
||||
export function AdministratorAbsencePage() {
|
||||
const [raw, setRaw] = useState<AdministratorAbsenceFormResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [selectedDates, setSelectedDates] = useState<Set<string>>(() => new Set())
|
||||
const [reasonType, setReasonType] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
const normalized = useMemo(() => (raw ? normalizeFormPayload(raw) : null), [raw])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const body = await fetchAdministratorAbsenceForm()
|
||||
setRaw(body)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load absence form.')
|
||||
setRaw(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalized) return
|
||||
const firstKey = Object.keys(normalized.reasonTypes)[0] ?? ''
|
||||
setReasonType((current) => current || firstKey)
|
||||
}, [normalized])
|
||||
|
||||
function toggleDate(ymd: string) {
|
||||
setSelectedDates((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(ymd)) next.delete(ymd)
|
||||
else next.add(ymd)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSuccess(null)
|
||||
setError(null)
|
||||
const dates = Array.from(selectedDates).sort()
|
||||
if (dates.length === 0) {
|
||||
setError('Select at least one date to request absence.')
|
||||
return
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError('Please enter a reason.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await submitAdministratorAbsence({
|
||||
dates,
|
||||
reason_type: reasonType || undefined,
|
||||
reason: reason.trim(),
|
||||
})
|
||||
const msg =
|
||||
res.message ??
|
||||
(typeof res.saved === 'number'
|
||||
? `Saved ${res.saved} date(s).`
|
||||
: 'Absence request submitted.')
|
||||
setSuccess(msg)
|
||||
setSelectedDates(new Set())
|
||||
setReason('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to submit absence request.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-4" style={{ maxWidth: 920 }}>
|
||||
<div className="mb-4">
|
||||
<h1 className="h3 mb-1">Administrator absence</h1>
|
||||
<p className="text-muted mb-0">
|
||||
Request scheduled time away. Uses{' '}
|
||||
<code className="small">GET</code> / <code className="small">POST</code>{' '}
|
||||
<code className="small">/api/v1/administrator/absence</code> with your session JWT.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted">Loading…</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="alert alert-success" role="alert">
|
||||
{success}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{normalized ? (
|
||||
<>
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-body">
|
||||
<div className="row g-2 small">
|
||||
<div className="col-md-4">
|
||||
<span className="text-muted">Admin</span>
|
||||
<div className="fw-semibold">{normalized.adminName}</div>
|
||||
</div>
|
||||
{normalized.semester ? (
|
||||
<div className="col-md-4">
|
||||
<span className="text-muted">Semester</span>
|
||||
<div className="fw-semibold">{normalized.semester}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{normalized.schoolYear ? (
|
||||
<div className="col-md-4">
|
||||
<span className="text-muted">School year</span>
|
||||
<div className="fw-semibold">{normalized.schoolYear}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header">
|
||||
<strong>Recorded absences</strong>
|
||||
</div>
|
||||
<div className="card-body p-0">
|
||||
{normalized.existing.length === 0 ? (
|
||||
<div className="p-3 text-muted small">No absence entries on file for this period.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped mb-0 align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{normalized.existing.map((row, i) => (
|
||||
<tr key={`${row.date}-${i}`}>
|
||||
<td>{row.date ? formatYmd(String(row.date)) : '—'}</td>
|
||||
<td>{row.status ?? '—'}</td>
|
||||
<td>{row.reason ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">
|
||||
<strong>Request absence</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{normalized.available.length === 0 ? (
|
||||
<p className="text-muted mb-0">
|
||||
No open dates are available to select right now. Check back later or contact the office if you need an
|
||||
exception.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={(e) => void onSubmit(e)}>
|
||||
<fieldset disabled={saving}>
|
||||
<legend className="form-label fw-semibold">Dates</legend>
|
||||
<div className="row row-cols-1 row-cols-md-2 g-2 mb-4">
|
||||
{normalized.available.map((ymd) => (
|
||||
<div key={ymd} className="col">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id={`abs-${ymd}`}
|
||||
checked={selectedDates.has(ymd)}
|
||||
onChange={() => toggleDate(ymd)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor={`abs-${ymd}`}>
|
||||
{formatYmd(ymd)}
|
||||
<span className="text-muted small ms-1">({ymd})</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="absenceReasonType">
|
||||
Reason type
|
||||
</label>
|
||||
<select
|
||||
id="absenceReasonType"
|
||||
className="form-select"
|
||||
value={reasonType}
|
||||
onChange={(e) => setReasonType(e.target.value)}
|
||||
>
|
||||
{Object.entries(normalized.reasonTypes).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="absenceReason">
|
||||
Details <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="absenceReason"
|
||||
className="form-control"
|
||||
rows={4}
|
||||
required
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Brief explanation for HR / scheduling records."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Submitting…' : 'Submit request'}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
fetchAdministratorAdminAttendance,
|
||||
saveAdministratorAdminAttendance,
|
||||
} from '../../api/session'
|
||||
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
||||
import type { AdministratorAdminAttendanceRow } from '../../api/types'
|
||||
|
||||
function formatUsDate(ymd: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||
const [y, m, d] = ymd.split('-')
|
||||
return `${m}-${d}-${y}`
|
||||
}
|
||||
|
||||
/** Port of `Views/attendance/admins_attendance_form.php` — admin staff daily attendance. */
|
||||
export function AdminsAttendanceFormPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
const date =
|
||||
searchParams.get('date') ?? new Date().toISOString().slice(0, 10)
|
||||
|
||||
const [rows, setRows] = useState<AdministratorAdminAttendanceRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchAdministratorAdminAttendance({
|
||||
date,
|
||||
semester: semester || null,
|
||||
schoolYear: schoolYear || null,
|
||||
})
|
||||
if (!cancelled) setRows(data.admins_grid ?? [])
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load admin attendance.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [date, semester, schoolYear])
|
||||
|
||||
const dateLabel = useMemo(() => formatUsDate(date), [date])
|
||||
|
||||
function setField(
|
||||
userId: number,
|
||||
patch: Partial<Pick<AdministratorAdminAttendanceRow, 'status' | 'reason'>>,
|
||||
) {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
(r.user_id ?? 0) === userId ? { ...r, ...patch } : r,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function markAllPresent() {
|
||||
setRows((prev) => prev.map((r) => ({ ...r, status: 'present' })))
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setRows((prev) => prev.map((r) => ({ ...r, status: '', reason: '' })))
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
setError(null)
|
||||
try {
|
||||
const admins: Record<
|
||||
string,
|
||||
{ status?: string | null; reason?: string | null; role_name?: string | null }
|
||||
> = {}
|
||||
for (const r of rows) {
|
||||
const uid = r.user_id ?? 0
|
||||
if (!uid) continue
|
||||
admins[String(uid)] = {
|
||||
status: r.status ?? '',
|
||||
reason: r.reason ?? '',
|
||||
role_name: r.role_name ?? 'Admin',
|
||||
}
|
||||
}
|
||||
await saveAdministratorAdminAttendance({
|
||||
date,
|
||||
semester,
|
||||
school_year: schoolYear,
|
||||
admins,
|
||||
})
|
||||
setMessage('Saved.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(next: { school_year: string; semester: string; date: string }) {
|
||||
const p = new URLSearchParams()
|
||||
if (next.school_year) p.set('school_year', next.school_year)
|
||||
if (next.semester) p.set('semester', next.semester)
|
||||
if (next.date) p.set('date', next.date)
|
||||
setSearchParams(p)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-xxl py-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h2 className="mb-0">Admin Attendance</h2>
|
||||
<div className="d-flex gap-2">
|
||||
<Link
|
||||
className="btn btn-outline-secondary"
|
||||
to={`${TEACHER_ATTENDANCE_MONTH_PATH}?${new URLSearchParams({ date, semester, school_year: schoolYear }).toString()}`}
|
||||
>
|
||||
← Back to Monthly Attendance
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? <div className="alert alert-success">{message}</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<form
|
||||
className="row gy-2 gx-3 align-items-end mb-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
applyFilters({
|
||||
school_year: String(fd.get('school_year') ?? ''),
|
||||
semester: String(fd.get('semester') ?? ''),
|
||||
date: String(fd.get('date') ?? ''),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label">School Year</label>
|
||||
<input
|
||||
type="text"
|
||||
name="school_year"
|
||||
className="form-control"
|
||||
defaultValue={schoolYear}
|
||||
placeholder="e.g. 2025-2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-2">
|
||||
<label className="form-label">Semester</label>
|
||||
<input
|
||||
type="text"
|
||||
name="semester"
|
||||
className="form-control"
|
||||
defaultValue={semester}
|
||||
placeholder="Fall"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label">Date</label>
|
||||
<input type="date" name="date" className="form-control" defaultValue={date} />
|
||||
</div>
|
||||
<div className="col-sm-4 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => setSearchParams(new URLSearchParams())}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<strong>Admins on {dateLabel}</strong>
|
||||
<span className="badge bg-secondary ms-2">{rows.length}</span>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button type="button" className="btn btn-sm btn-outline-success" onClick={markAllPresent}>
|
||||
Mark All Present
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={clearAll}>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-body table-responsive">
|
||||
{loading ? (
|
||||
<p className="text-muted mb-0">Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="alert alert-info mb-0">No Admin users found for this day/term.</div>
|
||||
) : (
|
||||
<table className="table table-bordered table-striped align-middle" id="adminsAttTable">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th className="text-center" style={{ width: 70 }}>
|
||||
#
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th className="text-center" style={{ width: 180 }}>
|
||||
Role
|
||||
</th>
|
||||
<th className="text-center" style={{ width: 220 }}>
|
||||
Status
|
||||
</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => {
|
||||
const uid = row.user_id ?? 0
|
||||
const name =
|
||||
`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `User#${uid}`
|
||||
const stat = String(row.status ?? '').toLowerCase()
|
||||
return (
|
||||
<tr key={uid || idx}>
|
||||
<td className="text-center">{idx + 1}</td>
|
||||
<td>{name}</td>
|
||||
<td className="text-center">{row.role_name ?? 'Admin'}</td>
|
||||
<td className="text-center">
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={stat === 'present' || stat === 'absent' || stat === 'late' ? stat : ''}
|
||||
onChange={(ev) =>
|
||||
setField(uid, { status: ev.target.value || null })
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="present">Present</option>
|
||||
<option value="absent">Absent</option>
|
||||
<option value="late">Late</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Optional reason"
|
||||
value={row.reason ?? ''}
|
||||
onChange={(ev) => setField(uid, { reason: ev.target.value })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card-footer d-flex justify-content-end">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || rows.length === 0}>
|
||||
{saving ? 'Saving…' : 'Save Admins Attendance'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { type FormEvent, useId, useState, type ChangeEvent } from 'react'
|
||||
import { updateAdministratorAttendance } from '../../api/session'
|
||||
|
||||
export type AttendanceEditModalProps = {
|
||||
studentId: string | number
|
||||
studentFirst?: string | null
|
||||
studentLast?: string | null
|
||||
classId: number
|
||||
sectionId: number
|
||||
/** Called after successful save */
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of `Views/attendance/edit_modal.php` — embedded in flows that need inline attendance edits.
|
||||
* Mount inside a Bootstrap modal wrapper or render with `show` pattern.
|
||||
*/
|
||||
export function AttendanceEditModalBody({
|
||||
studentId,
|
||||
studentFirst,
|
||||
studentLast,
|
||||
classId,
|
||||
sectionId,
|
||||
onSaved,
|
||||
}: AttendanceEditModalProps) {
|
||||
const uid = useId()
|
||||
const statusId = `status-${uid}`
|
||||
const dateId = `date-${uid}`
|
||||
const reasonId = `reason-${uid}`
|
||||
const reportedId = `reported-${uid}`
|
||||
|
||||
const [status, setStatus] = useState('present')
|
||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [reason, setReason] = useState('')
|
||||
const [reported, setReported] = useState('0')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const name = `${studentFirst ?? ''} ${studentLast ?? ''}`.trim() || `Student #${studentId}`
|
||||
|
||||
const reasonRequired = status === 'absent' || status === 'late'
|
||||
const showReported = status === 'absent'
|
||||
|
||||
function onStatusChange(ev: ChangeEvent<HTMLSelectElement>) {
|
||||
const v = ev.target.value
|
||||
setStatus(v)
|
||||
if (v !== 'absent') setReported('0')
|
||||
if (v === 'present') setReason('')
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateAdministratorAttendance({
|
||||
class_section_id: sectionId,
|
||||
student_id: Number(studentId),
|
||||
class_id: classId,
|
||||
status: status as 'present' | 'absent' | 'late',
|
||||
date,
|
||||
reason: reasonRequired ? reason : '',
|
||||
is_reported: showReported ? (reported === '1' ? 'yes' : 'no') : undefined,
|
||||
})
|
||||
onSaved?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor={statusId}>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id={statusId}
|
||||
className="form-select attendance-status"
|
||||
required
|
||||
value={status}
|
||||
onChange={onStatusChange}
|
||||
>
|
||||
<option value="present">Present</option>
|
||||
<option value="late">Late</option>
|
||||
<option value="absent">Absent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor={dateId}>
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
id={dateId}
|
||||
type="date"
|
||||
className="form-control"
|
||||
required
|
||||
value={date}
|
||||
onChange={(ev) => setDate(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor={reasonId}>
|
||||
Reason
|
||||
</label>
|
||||
<textarea
|
||||
id={reasonId}
|
||||
name="reason"
|
||||
className="form-control attendance-reason"
|
||||
rows={3}
|
||||
placeholder="Enter reason for absence or lateness"
|
||||
value={reason}
|
||||
disabled={!reasonRequired}
|
||||
required={reasonRequired}
|
||||
onChange={(ev) => setReason(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`mb-3${showReported ? '' : ' d-none'}`}>
|
||||
<label className="form-label" htmlFor={reportedId}>
|
||||
Absence Reported?
|
||||
</label>
|
||||
<select
|
||||
id={reportedId}
|
||||
className="form-select"
|
||||
value={reported}
|
||||
onChange={(ev) => setReported(ev.target.value)}
|
||||
>
|
||||
<option value="0">No</option>
|
||||
<option value="1">Yes</option>
|
||||
</select>
|
||||
<div className="form-text">Select “Yes” if the parent/guardian reported this absence.</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="student_id" value={String(studentId)} />
|
||||
|
||||
<div className="modal-footer px-0 pb-0">
|
||||
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="small text-muted mb-0 mt-2">{name}</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { fetchAttendanceStudentViolationsView } from '../../api/session'
|
||||
import { ATTENDANCE_VIOLATIONS_PENDING_PATH } from './attendancePaths'
|
||||
|
||||
/** Port of `Views/attendance/view.php` — student violation history. */
|
||||
export function AttendanceStudentViolationsViewPage() {
|
||||
const { studentId } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const code = searchParams.get('code')
|
||||
const incidentDate = searchParams.get('incident_date')
|
||||
const includeNotified = searchParams.get('include_notified')
|
||||
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!studentId) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchAttendanceStudentViolationsView(studentId, {
|
||||
code,
|
||||
incident_date: incidentDate,
|
||||
include_notified: includeNotified ?? undefined,
|
||||
})
|
||||
if (!cancelled) setData(res)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [studentId, code, incidentDate, includeNotified])
|
||||
|
||||
const title = typeof data?.title === 'string' ? data.title : 'Attendance History'
|
||||
const attendance = (data?.attendance as Array<Record<string, unknown>> | undefined) ?? []
|
||||
const showViolationSummary = Boolean(data?.show_violation_summary)
|
||||
const viCode = data?.violation_code
|
||||
const viDateRaw = String(data?.violation_date ?? '')
|
||||
const viNotif = data?.violation_notified
|
||||
|
||||
return (
|
||||
<div className="container-xxl py-4">
|
||||
<h2 className="text-center mt-2 mb-3">{title}</h2>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<Link to={ATTENDANCE_VIOLATIONS_PENDING_PATH} className="btn btn-secondary mb-3">
|
||||
Back to Attendance
|
||||
</Link>
|
||||
|
||||
<p className="text-muted small mb-3">
|
||||
Showing absences and lates for this student within the violation window.
|
||||
</p>
|
||||
|
||||
<h5 className="card-title mb-3">Attendance Violations</h5>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped attendance-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Reason</th>
|
||||
<th>Notified</th>
|
||||
<th>Reported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{showViolationSummary && (viCode || viDateRaw) ? (
|
||||
<tr className="table-info">
|
||||
<td>{viDateRaw ? formatMdY(viDateRaw.slice(0, 10)) : '—'}</td>
|
||||
<td>{String(viCode ?? '—')}</td>
|
||||
<td>Violation summary</td>
|
||||
<td>{truthy(viNotif) ? 'Yes' : 'No'}</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{attendance.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted">
|
||||
No attendance violations found for this student.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
attendance.map((record, i) => (
|
||||
<ViolationRow key={i} record={record} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function truthy(v: unknown): boolean {
|
||||
if (v === true || v === 1) return true
|
||||
if (typeof v === 'string') return ['yes', '1', 'true'].includes(v.toLowerCase().trim())
|
||||
return false
|
||||
}
|
||||
|
||||
function ViolationRow({ record }: { record: Record<string, unknown> }) {
|
||||
const dateStr = String(record.date ?? '').slice(0, 10)
|
||||
const dateLabel = dateStr ? formatMdY(dateStr) : '—'
|
||||
const statusRaw = String(record.status ?? '').toLowerCase().trim()
|
||||
const reason = String(record.reason ?? '').trim()
|
||||
const isNotified = truthy(record.is_notified)
|
||||
const isReported = truthy(record.is_reported)
|
||||
|
||||
const isAbs =
|
||||
statusRaw === 'absent' ||
|
||||
statusRaw === 'abs' ||
|
||||
statusRaw === 'a' ||
|
||||
statusRaw.startsWith('abs')
|
||||
const isLate = statusRaw === 'late' || statusRaw === 'l' || statusRaw.startsWith('late')
|
||||
|
||||
let status = statusRaw !== '' ? capitalize(statusRaw) : 'Unknown'
|
||||
if (isAbs) status = isReported ? 'Reported Absence' : 'Unreported Absence'
|
||||
else if (isLate) status = isReported ? 'Reported Late' : 'Unreported Late'
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{dateLabel}</td>
|
||||
<td>{status}</td>
|
||||
<td>{reason !== '' ? reason : 'No reason provided'}</td>
|
||||
<td>{isNotified ? 'Yes' : 'No'}</td>
|
||||
<td>{isReported ? 'Yes' : 'No'}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s
|
||||
}
|
||||
|
||||
function formatMdY(ymd: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||
const [y, m, d] = ymd.split('-')
|
||||
return `${m}-${d}-${y}`
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import {
|
||||
deleteAttendanceCommentTemplate,
|
||||
fetchAttendanceCommentTemplates,
|
||||
saveAttendanceCommentTemplate,
|
||||
} from '../../api/session'
|
||||
import type { AttendanceCommentTemplate } from '../../api/types'
|
||||
|
||||
/** Port of `Views/attendance_templates/index.php` — comment templates CRUD. */
|
||||
export function AttendanceTemplatesIndexPage() {
|
||||
const [templates, setTemplates] = useState<AttendanceCommentTemplate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modal, setModal] = useState<AttendanceCommentTemplate | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchAttendanceCommentTemplates()
|
||||
if (!cancelled) setTemplates(data.templates ?? [])
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load templates.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function refreshTemplates() {
|
||||
try {
|
||||
const data = await fetchAttendanceCommentTemplates()
|
||||
setTemplates(data.templates ?? [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load templates.')
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(t?: AttendanceCommentTemplate) {
|
||||
setModal(
|
||||
t ?? {
|
||||
id: undefined,
|
||||
min_score: undefined,
|
||||
max_score: undefined,
|
||||
template_text: '',
|
||||
is_active: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onSave(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (!modal) return
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const data: Record<string, string> = {}
|
||||
fd.forEach((v, k) => {
|
||||
data[k] = String(v)
|
||||
})
|
||||
if (modal.id != null) data.id = String(modal.id)
|
||||
data.is_active = fd.get('is_active') === 'on' ? 'on' : ''
|
||||
try {
|
||||
await saveAttendanceCommentTemplate(data)
|
||||
setModal(null)
|
||||
await refreshTemplates()
|
||||
} catch {
|
||||
setError('Save failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(id: number | undefined) {
|
||||
if (id == null) return
|
||||
if (!confirm('Delete this template?')) return
|
||||
try {
|
||||
await deleteAttendanceCommentTemplate(id)
|
||||
await refreshTemplates()
|
||||
} catch {
|
||||
setError('Delete failed.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="mt-2 mb-3">Attendance Comment Templates</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<button type="button" className="btn btn-success" onClick={() => openModal()}>
|
||||
Add New Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Min Score</th>
|
||||
<th>Max Score</th>
|
||||
<th>Template Text</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center text-muted">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : templates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center text-muted">
|
||||
No templates.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
templates.map((t, ti) => (
|
||||
<tr key={t.id ?? `tpl-${ti}`}>
|
||||
<td>{t.id ?? '—'}</td>
|
||||
<td>{t.min_score ?? '—'}</td>
|
||||
<td>{t.max_score ?? '—'}</td>
|
||||
<td style={{ maxWidth: 420, whiteSpace: 'pre-wrap' }}>{t.template_text ?? ''}</td>
|
||||
<td>{t.is_active ? 'Yes' : 'No'}</td>
|
||||
<td className="d-flex gap-1">
|
||||
<button type="button" className="btn btn-sm btn-warning" onClick={() => openModal(t)}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => onDelete(t.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modal ? (
|
||||
<div className="modal show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,0.4)' }}>
|
||||
<div className="modal-dialog">
|
||||
<form className="modal-content" onSubmit={onSave}>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{modal.id ? 'Edit Template' : 'New Template'}</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={() => setModal(null)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<input type="hidden" name="id" value={modal.id ?? ''} />
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Min Score</label>
|
||||
<input
|
||||
type="number"
|
||||
name="min_score"
|
||||
className="form-control"
|
||||
required
|
||||
defaultValue={modal.min_score ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Max Score</label>
|
||||
<input
|
||||
type="number"
|
||||
name="max_score"
|
||||
className="form-control"
|
||||
required
|
||||
defaultValue={modal.max_score ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Template Text</label>
|
||||
<textarea
|
||||
name="template_text"
|
||||
className="form-control"
|
||||
required
|
||||
rows={4}
|
||||
defaultValue={modal.template_text ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
id="is_active"
|
||||
defaultChecked={Boolean(modal.is_active)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="is_active">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setModal(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchAttendanceTrackingStudents, recordAttendanceTrackingEntry } from '../../api/session'
|
||||
|
||||
/** Port of `Views/attendance/attendance_tracking.php` — record + student summary list. */
|
||||
export function AttendanceTrackingPage() {
|
||||
const [title, setTitle] = useState('Attendance Tracking')
|
||||
const [students, setStudents] = useState<Array<Record<string, unknown>>>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [studentId, setStudentId] = useState('')
|
||||
const [recDate, setRecDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [isPresent, setIsPresent] = useState('1')
|
||||
const [reason, setReason] = useState('')
|
||||
const [isReported, setIsReported] = useState(false)
|
||||
const [notified, setNotified] = useState(false)
|
||||
const [flash, setFlash] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchAttendanceTrackingStudents()
|
||||
if (!cancelled) {
|
||||
setTitle(typeof data.title === 'string' ? data.title : 'Attendance Tracking')
|
||||
setStudents(Array.isArray(data.students) ? data.students : [])
|
||||
setError(null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function onRecord(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setFlash(null)
|
||||
try {
|
||||
await recordAttendanceTrackingEntry({
|
||||
student_id: Number(studentId),
|
||||
date: recDate,
|
||||
is_present: isPresent === '1',
|
||||
reason: isPresent === '0' ? reason : '',
|
||||
is_reported: isReported ? 1 : 0,
|
||||
notified: notified ? 1 : 0,
|
||||
})
|
||||
setFlash('Recorded.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Record failed.')
|
||||
}
|
||||
}
|
||||
|
||||
const showReason = isPresent === '0'
|
||||
|
||||
return (
|
||||
<div className="container-xxl py-4">
|
||||
<h2 className="text-center mt-2 mb-3">{title}</h2>
|
||||
{flash ? <div className="alert alert-success">{flash}</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">
|
||||
<h5 className="mb-0">Record Attendance</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={onRecord}>
|
||||
<div className="row">
|
||||
<div className="col-md-4 mb-3">
|
||||
<label className="form-label" htmlFor="student_id">
|
||||
Student
|
||||
</label>
|
||||
<select
|
||||
id="student_id"
|
||||
className="form-select"
|
||||
required
|
||||
value={studentId}
|
||||
onChange={(ev) => setStudentId(ev.target.value)}
|
||||
>
|
||||
<option value="">Select Student</option>
|
||||
{students.map((s, i) => {
|
||||
const id = String(s.id ?? s.student_id ?? '')
|
||||
const name = String((s.name ?? `${s.firstname ?? ''} ${s.lastname ?? ''}`.trim()) || id)
|
||||
return (
|
||||
<option key={id || String(i)} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3 mb-3">
|
||||
<label className="form-label" htmlFor="date">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
id="date"
|
||||
type="date"
|
||||
className="form-control"
|
||||
required
|
||||
value={recDate}
|
||||
onChange={(ev) => setRecDate(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3 mb-3">
|
||||
<label className="form-label" htmlFor="is_present">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="is_present"
|
||||
className="form-select"
|
||||
required
|
||||
value={isPresent}
|
||||
onChange={(ev) => setIsPresent(ev.target.value)}
|
||||
>
|
||||
<option value="1">Present</option>
|
||||
<option value="0">Absent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2 mb-3 d-flex align-items-end">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showReason ? (
|
||||
<div className="row mt-2">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label" htmlFor="reason">
|
||||
Reason for Absence
|
||||
</label>
|
||||
<textarea
|
||||
id="reason"
|
||||
className="form-control"
|
||||
rows={2}
|
||||
value={reason}
|
||||
onChange={(ev) => setReason(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6 mb-3 d-flex align-items-end gap-4 flex-wrap">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="is_reported"
|
||||
checked={isReported}
|
||||
onChange={(ev) => setIsReported(ev.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="is_reported">
|
||||
Reported Absence
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="notified"
|
||||
checked={notified}
|
||||
onChange={(ev) => setNotified(ev.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="notified">
|
||||
Parent Notified
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h5 className="mb-0">Student List</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{loading ? (
|
||||
<p className="text-muted mb-0">Loading students…</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student Name</th>
|
||||
<th>Total Absences</th>
|
||||
<th>Last Absence Date</th>
|
||||
<th>Last Absence Reason</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map((student, idx) => {
|
||||
const sid = String(student.id ?? student.student_id ?? idx)
|
||||
const name = String(student.name ?? `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim())
|
||||
const totalAbs = student.total_absences
|
||||
const last = student.last_absence as Record<string, unknown> | undefined
|
||||
const lastDate = last?.date ? String(last.date).slice(0, 10) : ''
|
||||
const lastReason = last?.reason != null ? String(last.reason) : ''
|
||||
return (
|
||||
<tr key={sid}>
|
||||
<td>{name}</td>
|
||||
<td>{totalAbs != null ? String(totalAbs) : '—'}</td>
|
||||
<td>{lastDate ? formatMdY(lastDate) : 'N/A'}</td>
|
||||
<td>{lastReason || 'N/A'}</td>
|
||||
<td>
|
||||
<Link className="btn btn-sm btn-info" to={`/app/administrator/attendance/view/${sid}`}>
|
||||
View Details
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatMdY(ymd: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||
const [y, m, d] = ymd.split('-')
|
||||
return `${m}-${d}-${y}`
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { type FormEvent, useMemo } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
ATTENDANCE_VIOLATIONS_NOTIFIED_PATH,
|
||||
ATTENDANCE_VIOLATIONS_PENDING_PATH,
|
||||
} from './attendancePaths'
|
||||
|
||||
/** Landing screen for attendance violations — links to pending / notified lists (same APIs as subpages). */
|
||||
export function AttendanceViolationsHubPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const filterQuery = useMemo(() => {
|
||||
const s = searchParams.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}, [searchParams])
|
||||
|
||||
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const p = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
if (sy) p.set('school_year', sy)
|
||||
if (sem) p.set('semester', sem)
|
||||
setSearchParams(p)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid px-0">
|
||||
<h2 className="text-center mt-4 mb-3">Attendance Violations</h2>
|
||||
<p className="text-center text-muted small mb-4">
|
||||
Review students who have crossed absence or late thresholds. Data loads on each list via the API.
|
||||
</p>
|
||||
|
||||
<div className="d-flex justify-content-center mb-4">
|
||||
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">School Year</label>
|
||||
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">Semester</label>
|
||||
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button type="submit" className="btn btn-sm btn-secondary">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="row g-3 justify-content-center px-2 pb-4">
|
||||
<div className="col-12 col-md-5">
|
||||
<div className="card border-0 rounded-3 shadow-sm h-100">
|
||||
<div className="card-body d-flex flex-column">
|
||||
<h5 className="card-title">Pending</h5>
|
||||
<p className="card-text small text-muted flex-grow-1">
|
||||
Students who need notification or follow-up per policy (team notify, auto email, etc.).
|
||||
</p>
|
||||
<Link
|
||||
className="btn btn-primary"
|
||||
to={`${ATTENDANCE_VIOLATIONS_PENDING_PATH}${filterQuery}`}
|
||||
>
|
||||
Open pending list
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-5">
|
||||
<div className="card border-0 rounded-3 shadow-sm h-100">
|
||||
<div className="card-body d-flex flex-column">
|
||||
<h5 className="card-title">Notified</h5>
|
||||
<p className="card-text small text-muted flex-grow-1">
|
||||
Students whose parents were already notified; add or edit notes as needed.
|
||||
</p>
|
||||
<Link
|
||||
className="btn btn-outline-primary"
|
||||
to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`}
|
||||
>
|
||||
Open notified list
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { fetchAttendanceComposeEmailContext, sendAttendanceViolationEmail } from '../../api/session'
|
||||
import { ATTENDANCE_VIOLATIONS_PENDING_PATH } from './attendancePaths'
|
||||
|
||||
/** Port of `Views/attendance/compose_email.php` — rich body via large textarea (TinyMCE optional later). */
|
||||
export function ComposeAttendanceEmailPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const studentId = searchParams.get('student_id') ?? ''
|
||||
const code = searchParams.get('code') ?? ''
|
||||
const variant = searchParams.get('variant') ?? ''
|
||||
const incidentDate =
|
||||
searchParams.get('incident_date') ?? new Date().toISOString().slice(0, 10)
|
||||
|
||||
const [to, setTo] = useState('')
|
||||
const [subject, setSubject] = useState('')
|
||||
const [bodyHtml, setBodyHtml] = useState('')
|
||||
const [studentName, setStudentName] = useState('')
|
||||
const [secondaryEmail, setSecondaryEmail] = useState('')
|
||||
const [secondaryName, setSecondaryName] = useState('')
|
||||
const [schoolYear, setSchoolYear] = useState('')
|
||||
const [semester, setSemester] = useState('')
|
||||
const missingParams = !studentId || !code
|
||||
const [loading, setLoading] = useState(!missingParams)
|
||||
const [error, setError] = useState<string | null>(
|
||||
missingParams ? 'Missing student_id or code query parameters.' : null,
|
||||
)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (missingParams) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const ctx = await fetchAttendanceComposeEmailContext({
|
||||
studentId,
|
||||
code,
|
||||
variant: variant || null,
|
||||
incidentDate,
|
||||
})
|
||||
if (cancelled) return
|
||||
setTo(String(ctx.parent_email ?? ctx.to ?? ''))
|
||||
setSubject(String(ctx.subject ?? ''))
|
||||
const initial = String(ctx.body_html ?? ctx.body_text ?? '')
|
||||
setBodyHtml(initial)
|
||||
setStudentName(String(ctx.student_name ?? ''))
|
||||
setSecondaryEmail(String(ctx.secondary_email ?? ''))
|
||||
setSecondaryName(String(ctx.secondary_name ?? ''))
|
||||
setSchoolYear(String(ctx.school_year ?? ''))
|
||||
setSemester(String(ctx.semester ?? ''))
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load compose context.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [studentId, code, variant, incidentDate, missingParams])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setMsg(null)
|
||||
try {
|
||||
await sendAttendanceViolationEmail({
|
||||
student_id: studentId,
|
||||
code,
|
||||
variant,
|
||||
incident_date: incidentDate,
|
||||
to,
|
||||
subject,
|
||||
body_html: bodyHtml,
|
||||
})
|
||||
setMsg('Email sent.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Send failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container my-4">
|
||||
<p className="text-muted">Loading compose form…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container my-4">
|
||||
<h2 className="text-center mb-3">Compose Attendance Email</h2>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{msg ? <div className="alert alert-success">{msg}</div> : null}
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<strong>{studentName}</strong>
|
||||
<span className="badge bg-secondary ms-2">
|
||||
{code}
|
||||
{variant ? ` • ${variant}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="badge bg-primary">School Year: {schoolYear}</span>
|
||||
{semester ? (
|
||||
<span className="badge bg-info text-dark ms-1">Semester: {semester}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">To</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
value={to}
|
||||
onChange={(ev) => setTo(ev.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="form-text">Primary parent email (you can change it here).</div>
|
||||
</div>
|
||||
|
||||
{secondaryEmail ? (
|
||||
<div className="alert alert-light border d-flex align-items-start mb-3">
|
||||
<div className="me-2">
|
||||
<i className="bi bi-files" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Second Parent (CC):</strong> {secondaryEmail}
|
||||
{secondaryName ? ` — ${secondaryName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={subject}
|
||||
onChange={(ev) => setSubject(ev.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Body (HTML allowed)</label>
|
||||
<textarea
|
||||
className="form-control font-monospace"
|
||||
rows={14}
|
||||
value={bodyHtml}
|
||||
onChange={(ev) => setBodyHtml(ev.target.value)}
|
||||
/>
|
||||
<div className="form-text">Paste HTML or plain text; backend may sanitize.</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
<Link to={ATTENDANCE_VIOLATIONS_PENDING_PATH} className="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</Link>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Sending…' : 'Send Email'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { createEarlyDismissal, fetchEarlyDismissalStudentOptions } from '../../api/session'
|
||||
import { EARLY_DISMISSALS_PATH } from './attendancePaths'
|
||||
|
||||
type StudentOpt = {
|
||||
id: number
|
||||
firstname: string
|
||||
lastname: string
|
||||
section?: string | null
|
||||
}
|
||||
|
||||
/** Port of `Views/attendance/early_dismissals_add.php` */
|
||||
export function EarlyDismissalsAddPage() {
|
||||
const [students, setStudents] = useState<StudentOpt[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [studentId, setStudentId] = useState<number | null>(null)
|
||||
const [label, setLabel] = useState('')
|
||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [dismissTime, setDismissTime] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await fetchEarlyDismissalStudentOptions()
|
||||
if (cancelled) return
|
||||
const raw = data.students ?? []
|
||||
setStudents(
|
||||
raw
|
||||
.map((s) => ({
|
||||
id: Number(s.id ?? 0),
|
||||
firstname: String(s.firstname ?? ''),
|
||||
lastname: String(s.lastname ?? ''),
|
||||
section: s.class_section_name ?? null,
|
||||
}))
|
||||
.filter((s) => s.id > 0),
|
||||
)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load students.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const enriched = useMemo(() => {
|
||||
const norm = (s: string) => s.toLowerCase().trim()
|
||||
return students.map((s) => {
|
||||
const display = `${s.firstname} ${s.lastname}${s.section ? ` — ${s.section}` : ''}`
|
||||
const key = `${norm(s.firstname)} ${norm(s.lastname)} ${norm(s.section ?? '')}`
|
||||
return { ...s, display, key }
|
||||
})
|
||||
}, [students])
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const q = query.toLowerCase().trim()
|
||||
if (!q) return []
|
||||
return enriched.filter((s) => s.key.includes(q)).slice(0, 20)
|
||||
}, [enriched, query])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setMsg(null)
|
||||
if (!studentId) {
|
||||
setError('Please select a student from the list.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createEarlyDismissal({
|
||||
student_id: studentId,
|
||||
date,
|
||||
dismiss_time: dismissTime,
|
||||
reason: reason || null,
|
||||
})
|
||||
setMsg('Early dismissal saved.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-xxl py-4">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<h2 className="mb-0">Add Early Dismissal</h2>
|
||||
<Link to={EARLY_DISMISSALS_PATH} className="btn btn-outline-secondary">
|
||||
Back to List
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{msg ? <div className="alert alert-success">{msg}</div> : null}
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body">
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="row gy-3">
|
||||
<div className="col-md-5 position-relative">
|
||||
<label className="form-label">Student</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control${error && !studentId ? ' is-invalid' : ''}`}
|
||||
autoComplete="off"
|
||||
placeholder="Type student name to search…"
|
||||
value={label}
|
||||
onChange={(ev) => {
|
||||
setLabel(ev.target.value)
|
||||
setStudentId(null)
|
||||
setQuery(ev.target.value)
|
||||
}}
|
||||
/>
|
||||
{query.trim() && matches.length > 0 ? (
|
||||
<div
|
||||
className="list-group position-absolute w-100 shadow-sm"
|
||||
style={{ zIndex: 1000, maxHeight: 240, overflow: 'auto' }}
|
||||
>
|
||||
{matches.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className="list-group-item list-group-item-action"
|
||||
onClick={() => {
|
||||
setStudentId(m.id)
|
||||
setLabel(m.display)
|
||||
setQuery('')
|
||||
}}
|
||||
>
|
||||
{m.display}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="form-text">Start typing first or last name, then pick from the list.</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Date (Sunday)</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
required
|
||||
value={date}
|
||||
onChange={(ev) => setDate(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Dismissal Time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="form-control"
|
||||
required
|
||||
value={dismissTime}
|
||||
onChange={(ev) => setDismissTime(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<label className="form-label">Reason (optional)</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
className="form-control"
|
||||
rows={2}
|
||||
placeholder="e.g., Family appointment"
|
||||
value={reason}
|
||||
onChange={(ev) => setReason(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-12 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
Save Early Dismissal
|
||||
</button>
|
||||
<Link to={EARLY_DISMISSALS_PATH} className="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 small text-muted">
|
||||
Note: Early dismissals do not change daily present/absent status; they are shown for staff awareness and
|
||||
pickup coordination.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { fetchEarlyDismissals, uploadEarlyDismissalSignature } from '../../api/session'
|
||||
import { apiUrl } from '../../lib/apiOrigin'
|
||||
import { EARLY_DISMISSALS_NEW_PATH, EARLY_DISMISSALS_PATH } from './attendancePaths'
|
||||
|
||||
function badgeClass(status: string): string {
|
||||
if (status === 'processed') return 'success'
|
||||
if (status === 'seen') return 'info'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/** Port of `Views/attendance/early_dismissals.php` */
|
||||
export function EarlyDismissalsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof fetchEarlyDismissals>> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [flash, setFlash] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchEarlyDismissals({
|
||||
schoolYear: schoolYear || null,
|
||||
semester: semester || null,
|
||||
})
|
||||
if (!cancelled) setData(res)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load early dismissals.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
async function onSignatureSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
try {
|
||||
await uploadEarlyDismissalSignature(fd)
|
||||
setFlash('Signature uploaded.')
|
||||
const res = await fetchEarlyDismissals({
|
||||
schoolYear: schoolYear || null,
|
||||
semester: semester || null,
|
||||
})
|
||||
setData(res)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed.')
|
||||
}
|
||||
}
|
||||
|
||||
const groups = data?.groups ?? {}
|
||||
const sigMap = data?.signatureByDate ?? {}
|
||||
|
||||
return (
|
||||
<div className="container-xxl py-4">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<div>
|
||||
<h2 className="mb-0">Early Dismissals</h2>
|
||||
<small className="text-muted">Parent-submitted early dismissal requests</small>
|
||||
</div>
|
||||
<Link to={EARLY_DISMISSALS_NEW_PATH} className="btn btn-primary">
|
||||
<i className="bi bi-plus-circle me-1" aria-hidden />
|
||||
Add Early Dismissal
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="row gy-2 gx-3 align-items-end mb-4"
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault()
|
||||
const fd = new FormData(ev.currentTarget)
|
||||
const p = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
if (sy) p.set('school_year', sy)
|
||||
if (sem) p.set('semester', sem)
|
||||
setSearchParams(p)
|
||||
}}
|
||||
>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">School Year</label>
|
||||
<input
|
||||
type="text"
|
||||
name="school_year"
|
||||
className="form-control"
|
||||
defaultValue={schoolYear}
|
||||
placeholder="e.g. 2025-2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Semester</label>
|
||||
<select name="semester" className="form-select" defaultValue={semester}>
|
||||
<option value="">All Semesters</option>
|
||||
<option value="Fall">Fall</option>
|
||||
<option value="Spring">Spring</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-secondary mt-4">
|
||||
Apply
|
||||
</button>
|
||||
<Link className="btn btn-outline-secondary mt-4" to={EARLY_DISMISSALS_PATH}>
|
||||
Reset
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{flash ? <div className="alert alert-success">{flash}</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : Object.keys(groups).length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No early dismissals found for the selected school year{semester ? ' and semester' : ''}.
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(groups).map(([date, rows]) => {
|
||||
const dateLabel =
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(date) && date !== 'Unknown'
|
||||
? formatMdY(date)
|
||||
: date
|
||||
const sig = sigMap[date]
|
||||
return (
|
||||
<div className="card shadow-sm mb-3" key={date}>
|
||||
<div className="card-body">
|
||||
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<strong>{dateLabel}</strong>
|
||||
{date !== 'Unknown' ? (
|
||||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||||
{sig?.filename ? (
|
||||
<>
|
||||
<a
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
href={apiUrl(`/api/v1/files/early-dismissal-signatures/${encodeURIComponent(sig.filename ?? '')}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Signature
|
||||
</a>
|
||||
{sig.original_name ? (
|
||||
<span className="small text-muted">{sig.original_name}</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="small text-muted">No signature uploaded</span>
|
||||
)}
|
||||
<form className="d-flex flex-wrap align-items-center gap-2" onSubmit={onSignatureSubmit}>
|
||||
<input type="hidden" name="report_date" value={date} />
|
||||
<input type="hidden" name="school_year" value={schoolYear} />
|
||||
<input type="hidden" name="semester" value={semester} />
|
||||
<input type="hidden" name="return_url" value={typeof window !== 'undefined' ? window.location.href : ''} />
|
||||
<input
|
||||
type="file"
|
||||
name="signature_file"
|
||||
className="form-control form-control-sm"
|
||||
accept=".jpg,.jpeg,.png,.webp,.gif,.pdf"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn btn-sm btn-primary">
|
||||
{sig?.filename ? 'Replace' : 'Upload'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Class/Section</th>
|
||||
<th>Dismissal Time</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(rows as Array<Record<string, unknown>>).map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{`${r.firstname ?? ''} ${r.lastname ?? ''}`.trim()}</td>
|
||||
<td>{String(r.class_section_name ?? '—')}</td>
|
||||
<td>
|
||||
{r.dismiss_time ? String(String(r.dismiss_time).slice(0, 5)) : '—'}
|
||||
</td>
|
||||
<td style={{ maxWidth: 420, whiteSpace: 'normal' }}>{String(r.reason ?? '')}</td>
|
||||
<td>
|
||||
<span className={`badge bg-${badgeClass(String(r.status ?? '').toLowerCase())}`}>
|
||||
{String(r.status ?? 'new').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatMdY(ymd: string): string {
|
||||
const [y, m, d] = ymd.split('-')
|
||||
return `${m}-${d}-${y}`
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { fetchParentAttendanceReportsAdmin } from '../../api/session'
|
||||
import type { ParentAttendanceAdminReportRow } from '../../api/types'
|
||||
|
||||
/** Port of `Views/attendance/parent_reports.php` */
|
||||
export function ParentAttendanceReportsAdminPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
const start = searchParams.get('start') ?? new Date().toISOString().slice(0, 10)
|
||||
const end = searchParams.get('end') ?? ''
|
||||
|
||||
const [rows, setRows] = useState<ParentAttendanceAdminReportRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentAttendanceReportsAdmin({
|
||||
schoolYear: schoolYear || null,
|
||||
semester: semester || null,
|
||||
start: start || null,
|
||||
end: end || null,
|
||||
})
|
||||
if (!cancelled) setRows(data.rows ?? [])
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load reports.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester, start, end])
|
||||
|
||||
function apply(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const p = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
const st = String(fd.get('start') ?? '')
|
||||
const en = String(fd.get('end') ?? '')
|
||||
if (sy) p.set('school_year', sy)
|
||||
if (sem) p.set('semester', sem)
|
||||
if (st) p.set('start', st)
|
||||
if (en) p.set('end', en)
|
||||
setSearchParams(p)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-xxl py-4">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<h2 className="mb-0">Parent Attendance Reports</h2>
|
||||
<small className="text-muted">Submitted by parents (absent/late/early dismissal)</small>
|
||||
</div>
|
||||
|
||||
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={apply}>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">School Year</label>
|
||||
<input
|
||||
type="text"
|
||||
name="school_year"
|
||||
className="form-control"
|
||||
defaultValue={schoolYear}
|
||||
placeholder="e.g. 2025-2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Semester</label>
|
||||
<select name="semester" className="form-select" defaultValue={semester}>
|
||||
<option value="">—</option>
|
||||
<option value="Fall">Fall</option>
|
||||
<option value="Spring">Spring</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Start Date</label>
|
||||
<input type="date" name="start" className="form-control" defaultValue={start} />
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">End Date</label>
|
||||
<input type="date" name="end" className="form-control" defaultValue={end} />
|
||||
</div>
|
||||
<div className="col-md-3 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-secondary mt-4">
|
||||
Apply
|
||||
</button>
|
||||
<Link className="btn btn-outline-secondary mt-4" to="/app/administrator/attendance/parent-reports">
|
||||
Reset
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body table-responsive">
|
||||
{loading ? (
|
||||
<p className="text-muted mb-0">Loading…</p>
|
||||
) : (
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Student</th>
|
||||
<th>Class/Section</th>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-muted">
|
||||
No reports found for the selected range.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{formatCellDate(r.report_date)}</td>
|
||||
<td>{`${String(r['firstname'] ?? '')} ${String(r['lastname'] ?? '')}`.trim()}</td>
|
||||
<td>{String(r.class_section_name ?? '—')}</td>
|
||||
<td className="text-capitalize">{String(r.type ?? '').replace(/_/g, ' ')}</td>
|
||||
<td>{timeCell(r)}</td>
|
||||
<td style={{ maxWidth: 360, whiteSpace: 'normal' }}>{String(r.reason ?? '')}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge bg-${
|
||||
r.status === 'processed' ? 'success' : r.status === 'seen' ? 'info' : 'secondary'
|
||||
}`}
|
||||
>
|
||||
{String(r.status ?? 'new').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCellDate(v: unknown): string {
|
||||
if (v == null || v === '') return ''
|
||||
const s = String(v).slice(0, 10)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return String(v)
|
||||
const [y, m, d] = s.split('-')
|
||||
return `${m}-${d}-${y}`
|
||||
}
|
||||
|
||||
function timeCell(r: ParentAttendanceAdminReportRow): string {
|
||||
const t = String(r.type ?? '')
|
||||
const arrival = r['arrival_time']
|
||||
const dismiss = r['dismiss_time']
|
||||
if (t === 'late' && arrival) return `Arrival: ${String(arrival).slice(0, 5)}`
|
||||
if (t === 'early_dismissal' && dismiss) return `Dismiss: ${String(dismiss).slice(0, 5)}`
|
||||
return '—'
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session'
|
||||
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
||||
import type { TeacherStaffAttendanceRow } from '../../api/types'
|
||||
|
||||
function formatUsDate(ymd: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||
const [y, m, d] = ymd.split('-')
|
||||
return `${m}-${d}-${y}`
|
||||
}
|
||||
|
||||
/** Port of `Views/attendance/teacher_attendance_form.php` */
|
||||
export function TeacherAttendanceFormPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
const date =
|
||||
searchParams.get('date') ?? new Date().toISOString().slice(0, 10)
|
||||
const classSectionId = Number(searchParams.get('class_section_id') ?? '0')
|
||||
|
||||
const [sections, setSections] = useState<Record<string, string>>({})
|
||||
const [grid, setGrid] = useState<TeacherStaffAttendanceRow[]>([])
|
||||
const [locked, setLocked] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchTeacherStaffAttendance({
|
||||
date,
|
||||
semester: semester || null,
|
||||
schoolYear: schoolYear || null,
|
||||
classSectionId: classSectionId || null,
|
||||
})
|
||||
if (cancelled) return
|
||||
setSections(data.sections ?? {})
|
||||
setGrid(data.grid ?? [])
|
||||
setLocked(Boolean(data.locked))
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load teacher attendance.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [date, semester, schoolYear, classSectionId])
|
||||
|
||||
const dateLabel = useMemo(() => formatUsDate(date), [date])
|
||||
|
||||
function setRow(tid: number, patch: Partial<TeacherStaffAttendanceRow>) {
|
||||
setGrid((prev) =>
|
||||
prev.map((r) => ((r.teacher_id ?? 0) === tid ? { ...r, ...patch } : r)),
|
||||
)
|
||||
}
|
||||
|
||||
function markAllPresent() {
|
||||
setGrid((prev) => prev.map((r) => ({ ...r, status: 'present' })))
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setGrid((prev) => prev.map((r) => ({ ...r, status: '', reason: '' })))
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!classSectionId) return
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
setError(null)
|
||||
try {
|
||||
const teachers: Record<string, { status?: string | null; reason?: string | null }> = {}
|
||||
for (const r of grid) {
|
||||
const tid = r.teacher_id ?? 0
|
||||
if (!tid) continue
|
||||
teachers[String(tid)] = {
|
||||
status: r.status ?? '',
|
||||
reason: r.reason ?? '',
|
||||
}
|
||||
}
|
||||
await saveTeacherStaffAttendance({
|
||||
date,
|
||||
semester,
|
||||
school_year: schoolYear,
|
||||
class_section_id: classSectionId,
|
||||
teachers,
|
||||
})
|
||||
setMessage('Saved.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const p = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
const dt = String(fd.get('date') ?? '')
|
||||
const cs = String(fd.get('class_section_id') ?? '0')
|
||||
if (sy) p.set('school_year', sy)
|
||||
if (sem) p.set('semester', sem)
|
||||
if (dt) p.set('date', dt)
|
||||
if (cs && cs !== '0') p.set('class_section_id', cs)
|
||||
setSearchParams(p)
|
||||
}
|
||||
|
||||
const sectionEntries = Object.entries(sections)
|
||||
|
||||
return (
|
||||
<div className="container-xxl py-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h2 className="mb-0">Teacher Attendance</h2>
|
||||
<Link
|
||||
className="btn btn-outline-secondary"
|
||||
to={`${TEACHER_ATTENDANCE_MONTH_PATH}?${new URLSearchParams({ date, semester, school_year: schoolYear }).toString()}`}
|
||||
>
|
||||
← Back to Monthly Attendance
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{message ? <div className="alert alert-success">{message}</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label">School Year</label>
|
||||
<input
|
||||
type="text"
|
||||
name="school_year"
|
||||
className="form-control"
|
||||
defaultValue={schoolYear}
|
||||
placeholder="e.g. 2025-2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-2">
|
||||
<label className="form-label">Semester</label>
|
||||
<input
|
||||
type="text"
|
||||
name="semester"
|
||||
className="form-control"
|
||||
defaultValue={semester}
|
||||
placeholder="Fall"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label">Date</label>
|
||||
<input type="date" name="date" className="form-control" defaultValue={date} />
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<label className="form-label">Class Section</label>
|
||||
<select
|
||||
name="class_section_id"
|
||||
className="form-select"
|
||||
defaultValue={classSectionId || 0}
|
||||
>
|
||||
<option value="0">— Choose Section —</option>
|
||||
{sectionEntries.map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label} (ID: {id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
Load
|
||||
</button>
|
||||
{classSectionId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => setSearchParams(new URLSearchParams())}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{!classSectionId ? null : (
|
||||
<>
|
||||
{locked ? (
|
||||
<div className="alert alert-warning">
|
||||
<strong>Note:</strong> Student/section attendance for this day appears to be <em>finalized</em>. Admin edits
|
||||
to teacher attendance here are allowed and will not unlock the section.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<strong>Teachers on {dateLabel}</strong>
|
||||
<div className="d-flex gap-2">
|
||||
<button type="button" className="btn btn-sm btn-outline-success" onClick={markAllPresent}>
|
||||
Mark All Present
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={clearAll}>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body table-responsive">
|
||||
{loading ? (
|
||||
<p className="text-muted mb-0">Loading…</p>
|
||||
) : grid.length === 0 ? (
|
||||
<p className="text-muted mb-0 text-center">No teachers found for this section/term.</p>
|
||||
) : (
|
||||
<table className="table table-bordered table-striped align-middle" id="teacherAttTable">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th className="text-center" style={{ width: 70 }}>
|
||||
#
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th className="text-center" style={{ width: 160 }}>
|
||||
Role
|
||||
</th>
|
||||
<th className="text-center" style={{ width: 200 }}>
|
||||
Status
|
||||
</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grid.map((row, idx) => {
|
||||
const tid = row.teacher_id ?? 0
|
||||
const name =
|
||||
`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `User#${tid}`
|
||||
const pos = String(row.position ?? '').toLowerCase()
|
||||
const stat = String(row.status ?? '').toLowerCase()
|
||||
return (
|
||||
<tr key={tid || idx}>
|
||||
<td className="text-center">{idx + 1}</td>
|
||||
<td>{name}</td>
|
||||
<td className="text-center">{pos === 'main' ? 'Main Teacher' : 'Assistant'}</td>
|
||||
<td className="text-center">
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={
|
||||
stat === 'present' || stat === 'absent' || stat === 'late' ? stat : ''
|
||||
}
|
||||
onChange={(ev) => setRow(tid, { status: ev.target.value || null })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="present">Present</option>
|
||||
<option value="absent">Absent</option>
|
||||
<option value="late">Late</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Optional reason"
|
||||
value={row.reason ?? ''}
|
||||
onChange={(ev) => setRow(tid, { reason: ev.target.value })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer d-flex justify-content-end">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || grid.length === 0}>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/* From CI `teacher_attendance_month.php` embedded styles — monthly staff grids */
|
||||
.att-month-wrap .month-grid-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.att-month-wrap .month-grid thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.att-month-wrap .sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
background: #fff;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.att-month-wrap .sticky-col-2 {
|
||||
position: sticky;
|
||||
left: 180px;
|
||||
z-index: 3;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.att-month-wrap .sticky-col-3 {
|
||||
position: sticky;
|
||||
left: 460px;
|
||||
z-index: 3;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.att-month-wrap .no-school-day {
|
||||
background-color: #cff4fc !important;
|
||||
}
|
||||
|
||||
.att-month-wrap .att-select.form-select.form-select-sm {
|
||||
min-width: 54px;
|
||||
padding-left: 6px;
|
||||
padding-right: 18px;
|
||||
line-height: 1.1;
|
||||
border: 1px solid #adb5bd;
|
||||
}
|
||||
|
||||
.att-month-wrap .att-select.att-present {
|
||||
background: #157347;
|
||||
color: #fff;
|
||||
border-color: #0f5132;
|
||||
}
|
||||
|
||||
.att-month-wrap .att-select.att-absent {
|
||||
background: #bb2d3b;
|
||||
color: #fff;
|
||||
border-color: #842029;
|
||||
}
|
||||
|
||||
.att-month-wrap .att-select.att-late {
|
||||
background: #ffca2c;
|
||||
color: #111;
|
||||
border-color: #997404;
|
||||
}
|
||||
|
||||
.att-month-wrap .att-select.att-none {
|
||||
background: #fff;
|
||||
color: #212529;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
.att-month-wrap .badge-cell.bg-info {
|
||||
min-width: 32px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { apiUrl } from '../../lib/apiOrigin'
|
||||
import {
|
||||
fetchStaffMonthlyAttendanceOverview,
|
||||
saveStaffMonthlyAttendanceCell,
|
||||
} from '../../api/session'
|
||||
import type {
|
||||
StaffMonthlyAdminRow,
|
||||
StaffMonthlyOverviewPayload,
|
||||
StaffMonthlySection,
|
||||
StaffMonthlyTeacherRow,
|
||||
} from '../../api/types'
|
||||
import './TeacherAttendanceMonthPage.css'
|
||||
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
||||
|
||||
function inferSemesterByDate(dateStr: string, schoolYear: string): string {
|
||||
try {
|
||||
if (!schoolYear.includes('-')) return ''
|
||||
const parts = schoolYear.split('-')
|
||||
const y1 = parseInt(parts[0]!, 10)
|
||||
const y2 = parseInt(parts[1]!, 10)
|
||||
if (!y1 || !y2) return ''
|
||||
const fallStart = new Date(`${y1}-09-21T00:00:00Z`)
|
||||
const fallEnd = new Date(`${y2}-01-18T00:00:00Z`)
|
||||
const spStart = new Date(`${y2}-01-25T00:00:00Z`)
|
||||
const spEnd = new Date(`${y2}-05-30T00:00:00Z`)
|
||||
const d = new Date(`${dateStr}T00:00:00Z`)
|
||||
if (d >= fallStart && d <= fallEnd) return 'Fall'
|
||||
if (d >= spStart && d <= spEnd) return 'Spring'
|
||||
const m = d.getUTCMonth() + 1
|
||||
return m >= 9 || m <= 1 ? 'Fall' : 'Spring'
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function selectClass(
|
||||
value: string,
|
||||
): 'att-select form-select form-select-sm att-none' | 'att-select form-select form-select-sm att-present' | 'att-select form-select form-select-sm att-absent' | 'att-select form-select form-select-sm att-late' {
|
||||
const v = value.toLowerCase()
|
||||
if (v === 'present') return 'att-select form-select form-select-sm att-present'
|
||||
if (v === 'absent') return 'att-select form-select form-select-sm att-absent'
|
||||
if (v === 'late') return 'att-select form-select form-select-sm att-late'
|
||||
return 'att-select form-select form-select-sm att-none'
|
||||
}
|
||||
|
||||
/** Port of `Views/attendance/teacher_attendance_month.php` — interactive monthly grids (JWT; no CI CSRF). */
|
||||
export function TeacherAttendanceMonthPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const semesterParam = searchParams.get('semester') ?? '---'
|
||||
const schoolYearParam = searchParams.get('school_year') ?? ''
|
||||
|
||||
const [payload, setPayload] = useState<StaffMonthlyOverviewPayload | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchStaffMonthlyAttendanceOverview({
|
||||
semester: semesterParam === '---' ? null : semesterParam,
|
||||
schoolYear: schoolYearParam || null,
|
||||
})
|
||||
if (!cancelled) setPayload(data)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load monthly attendance.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [semesterParam, schoolYearParam])
|
||||
|
||||
const filters = payload?.filters ?? {}
|
||||
const sundays = payload?.sundays ?? []
|
||||
const noSchoolSet = useMemo(
|
||||
() => new Set(payload?.noSchoolDays ?? []),
|
||||
[payload?.noSchoolDays],
|
||||
)
|
||||
const isCurrentYear = payload?.isCurrentYear !== false
|
||||
const schoolYears = useMemo(() => {
|
||||
const raw = payload?.schoolYears ?? []
|
||||
const ys: string[] = []
|
||||
for (const y of raw) {
|
||||
if (typeof y === 'string') ys.push(y)
|
||||
else if (y && typeof y === 'object' && 'school_year' in y && y.school_year)
|
||||
ys.push(String(y.school_year))
|
||||
}
|
||||
if (ys.length === 0 && schoolYearParam) ys.push(schoolYearParam)
|
||||
return ys
|
||||
}, [payload?.schoolYears, schoolYearParam])
|
||||
|
||||
async function onCellChange(
|
||||
kind: 'teacher' | 'admin',
|
||||
patch: {
|
||||
userId: string
|
||||
date: string
|
||||
status: string
|
||||
roleName: string
|
||||
sectionCode?: string | null
|
||||
},
|
||||
) {
|
||||
if (!isCurrentYear) {
|
||||
setToast({ ok: false, text: 'Editing disabled for non-current year' })
|
||||
return
|
||||
}
|
||||
let semesterForSave = filters.semester ?? semesterParam
|
||||
if (semesterForSave === '---') {
|
||||
semesterForSave = inferSemesterByDate(patch.date, filters.school_year ?? schoolYearParam)
|
||||
}
|
||||
const body: Record<string, string> = {
|
||||
date: patch.date,
|
||||
status: patch.status,
|
||||
user_id: patch.userId,
|
||||
semester: semesterForSave,
|
||||
school_year: filters.school_year ?? schoolYearParam,
|
||||
role_name: patch.roleName,
|
||||
type: kind,
|
||||
}
|
||||
if (patch.sectionCode) body.section_id = patch.sectionCode
|
||||
|
||||
try {
|
||||
await saveStaffMonthlyAttendanceCell(body)
|
||||
setToast({ ok: true, text: 'Saved' })
|
||||
const data = await fetchStaffMonthlyAttendanceOverview({
|
||||
semester: semesterParam === '---' ? null : semesterParam,
|
||||
schoolYear: schoolYearParam || null,
|
||||
})
|
||||
setPayload(data)
|
||||
} catch {
|
||||
setToast({ ok: false, text: 'Save failed' })
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const p = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '---')
|
||||
if (sy) p.set('school_year', sy)
|
||||
p.set('semester', sem)
|
||||
setSearchParams(p)
|
||||
}
|
||||
|
||||
const queryString =
|
||||
filters.semester && filters.school_year
|
||||
? `?${new URLSearchParams({
|
||||
semester: filters.semester === '---' ? '' : filters.semester,
|
||||
school_year: filters.school_year,
|
||||
}).toString()}`
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-4 att-month-wrap">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<h2 className="mb-0">Admin & Teacher — Monthly Attendance</h2>
|
||||
<div className="legend small">
|
||||
<span className="badge bg-success">P</span>
|
||||
<span className="ms-1">Present</span>
|
||||
<span className="badge bg-danger ms-3">A</span>
|
||||
<span className="ms-1">Absent</span>
|
||||
<span className="badge bg-warning text-dark ms-3">L</span>
|
||||
<span className="ms-1">Late</span>
|
||||
<span className="badge bg-info text-dark ms-3">NS</span>
|
||||
<span className="ms-1">No School</span>
|
||||
<span className="ms-3 text-muted">—</span>
|
||||
<span className="ms-1">No entry</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toast ? (
|
||||
<div className={`alert alert-${toast.ok ? 'success' : 'danger'} py-2`}>{toast.text}</div>
|
||||
) : null}
|
||||
|
||||
{payload?.missingYear ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
Current school year is not configured. This page is read-only until configured (key:{' '}
|
||||
<code>school_year</code>).
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="row gy-2 gx-3 align-items-end justify-content-center mb-4" onSubmit={applyFilter}>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">School Year</label>
|
||||
<select name="school_year" className="form-select" defaultValue={schoolYearParam}>
|
||||
{schoolYears.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Semester</label>
|
||||
<select name="semester" className="form-select" defaultValue={semesterParam}>
|
||||
{['---', 'Fall', 'Spring'].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-4 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
Load
|
||||
</button>
|
||||
<Link className="btn btn-outline-secondary" to={TEACHER_ATTENDANCE_MONTH_PATH}>
|
||||
Reset
|
||||
</Link>
|
||||
<a
|
||||
className="btn btn-outline-primary"
|
||||
href={apiUrl(`/api/v1/attendance/staff/month-overview.csv${queryString}`)}
|
||||
>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<strong>
|
||||
Teachers & Assistants — Sundays in{' '}
|
||||
<span>{filters.range_label ?? filters.month_label ?? schoolYearParam}</span>
|
||||
</strong>
|
||||
{!isCurrentYear ? <span className="badge bg-secondary ms-2">Read-only (Past Year)</span> : null}
|
||||
</div>
|
||||
<small className="text-muted">Use the dropdowns to set attendance inline</small>
|
||||
</div>
|
||||
<div className="card-body month-grid-wrap">
|
||||
<TeachersGrid
|
||||
sections={payload?.sections ?? []}
|
||||
sundays={sundays}
|
||||
noSchoolSet={noSchoolSet}
|
||||
isCurrentYear={isCurrentYear}
|
||||
onCellChange={(p) => void onCellChange('teacher', p)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<strong>
|
||||
Admins — Sundays in{' '}
|
||||
<span>{filters.range_label ?? filters.month_label ?? schoolYearParam}</span>
|
||||
</strong>
|
||||
<span className="text-muted ms-2">(All roles except Parent/Guest/Teacher/Teacher Assistant)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body month-grid-wrap">
|
||||
<AdminsGrid
|
||||
admins={payload?.admins ?? []}
|
||||
sundays={sundays}
|
||||
noSchoolSet={noSchoolSet}
|
||||
isCurrentYear={isCurrentYear}
|
||||
onCellChange={(p) => void onCellChange('admin', p)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TeachersGrid({
|
||||
sections,
|
||||
sundays,
|
||||
noSchoolSet,
|
||||
isCurrentYear,
|
||||
onCellChange,
|
||||
}: {
|
||||
sections: StaffMonthlySection[]
|
||||
sundays: string[]
|
||||
noSchoolSet: Set<string>
|
||||
isCurrentYear: boolean
|
||||
onCellChange: (p: {
|
||||
userId: string
|
||||
date: string
|
||||
status: string
|
||||
roleName: string
|
||||
sectionCode?: string | null
|
||||
}) => void
|
||||
}) {
|
||||
if (!sections.length) {
|
||||
return <div className="alert alert-info mb-0">No sections with assigned teachers found for this term.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table table-bordered table-striped month-grid align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky-col" style={{ minWidth: 180 }}>
|
||||
Class / Section
|
||||
</th>
|
||||
<th className="sticky-col-2" style={{ minWidth: 280 }}>
|
||||
Teacher
|
||||
</th>
|
||||
<th className="sticky-col-3 text-center" style={{ minWidth: 120 }}>
|
||||
Role
|
||||
</th>
|
||||
{sundays.map((day) => (
|
||||
<th
|
||||
key={day}
|
||||
className={`text-center${noSchoolSet.has(day) ? ' no-school-day' : ''}`}
|
||||
style={{ minWidth: 56 }}
|
||||
title={day}
|
||||
>
|
||||
{day.slice(5, 7)}-{day.slice(8, 10)}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-center" style={{ minWidth: 70 }}>
|
||||
P
|
||||
</th>
|
||||
<th className="text-center" style={{ minWidth: 70 }}>
|
||||
A
|
||||
</th>
|
||||
<th className="text-center" style={{ minWidth: 70 }}>
|
||||
L
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.flatMap((section) => {
|
||||
const teachers = section.teachers ?? []
|
||||
const rowSpan = teachers.length || 1
|
||||
if (!teachers.length) return []
|
||||
return teachers.map((teacher, t) => (
|
||||
<TeacherRow
|
||||
key={`${section.section_code}-${teacher.user_id ?? teacher.teacher_id}-${t}`}
|
||||
section={section}
|
||||
teacher={teacher}
|
||||
tIndex={t}
|
||||
rowSpan={rowSpan}
|
||||
sundays={sundays}
|
||||
noSchoolSet={noSchoolSet}
|
||||
isCurrentYear={isCurrentYear}
|
||||
onCellChange={onCellChange}
|
||||
/>
|
||||
))
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function TeacherRow({
|
||||
section,
|
||||
teacher,
|
||||
tIndex,
|
||||
rowSpan,
|
||||
sundays,
|
||||
noSchoolSet,
|
||||
isCurrentYear,
|
||||
onCellChange,
|
||||
}: {
|
||||
section: StaffMonthlySection
|
||||
teacher: StaffMonthlyTeacherRow
|
||||
tIndex: number
|
||||
rowSpan: number
|
||||
sundays: string[]
|
||||
noSchoolSet: Set<string>
|
||||
isCurrentYear: boolean
|
||||
onCellChange: (p: {
|
||||
userId: string
|
||||
date: string
|
||||
status: string
|
||||
roleName: string
|
||||
sectionCode?: string | null
|
||||
}) => void
|
||||
}) {
|
||||
const uid = String(teacher.user_id ?? teacher.teacher_id ?? '')
|
||||
const roleText =
|
||||
String(teacher.position ?? '').toLowerCase() === 'main' ? 'Main' : 'Assistant'
|
||||
const totals = teacher.totals ?? {}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
{tIndex === 0 ? (
|
||||
<td className="sticky-col" rowSpan={rowSpan} style={{ minWidth: 180 }}>
|
||||
{section.label ?? section.section_code ?? '—'}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="sticky-col-2">{teacher.name ?? '—'}</td>
|
||||
<td className="sticky-col-3 text-center">{roleText}</td>
|
||||
{sundays.map((day) => {
|
||||
if (noSchoolSet.has(day)) {
|
||||
return (
|
||||
<td key={day} className="text-center no-school-day">
|
||||
<span className="badge-cell bg-info text-dark">NS</span>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
const st = String(teacher.cells?.[day]?.status ?? '').toLowerCase()
|
||||
const val = st === 'present' || st === 'absent' || st === 'late' ? st : ''
|
||||
return (
|
||||
<td key={day} className="text-center">
|
||||
<select
|
||||
disabled={!isCurrentYear}
|
||||
className={selectClass(val)}
|
||||
value={val}
|
||||
onChange={(ev) =>
|
||||
onCellChange({
|
||||
userId: uid,
|
||||
date: day,
|
||||
status: ev.target.value,
|
||||
roleName: roleText,
|
||||
sectionCode: section.section_code ?? null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="present">P</option>
|
||||
<option value="absent">A</option>
|
||||
<option value="late">L</option>
|
||||
</select>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="text-center">
|
||||
<strong>{totals.p ?? 0}</strong>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<strong>{totals.a ?? 0}</strong>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<strong>{totals.l ?? 0}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminsGrid({
|
||||
admins,
|
||||
sundays,
|
||||
noSchoolSet,
|
||||
isCurrentYear,
|
||||
onCellChange,
|
||||
}: {
|
||||
admins: StaffMonthlyAdminRow[]
|
||||
sundays: string[]
|
||||
noSchoolSet: Set<string>
|
||||
isCurrentYear: boolean
|
||||
onCellChange: (p: {
|
||||
userId: string
|
||||
date: string
|
||||
status: string
|
||||
roleName: string
|
||||
sectionCode?: string | null
|
||||
}) => void
|
||||
}) {
|
||||
if (!admins.length) {
|
||||
return <div className="alert alert-info mb-0">No admin users found for this term.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table table-bordered table-striped month-grid align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky-col" style={{ minWidth: 180 }}>
|
||||
Admin
|
||||
</th>
|
||||
<th className="sticky-col-2 text-center" style={{ minWidth: 240 }}>
|
||||
Role
|
||||
</th>
|
||||
{sundays.map((day) => (
|
||||
<th
|
||||
key={day}
|
||||
className={`text-center${noSchoolSet.has(day) ? ' no-school-day' : ''}`}
|
||||
style={{ minWidth: 56 }}
|
||||
title={day}
|
||||
>
|
||||
{day.slice(5, 7)}-{day.slice(8, 10)}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-center">P</th>
|
||||
<th className="text-center">A</th>
|
||||
<th className="text-center">L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{admins.map((admin) => {
|
||||
const uid = String(admin.user_id ?? '')
|
||||
const totals = admin.totals ?? {}
|
||||
return (
|
||||
<tr key={uid}>
|
||||
<td className="sticky-col">{admin.name ?? '—'}</td>
|
||||
<td className="sticky-col-2 text-center">{admin.role ?? '—'}</td>
|
||||
{sundays.map((day) => {
|
||||
if (noSchoolSet.has(day)) {
|
||||
return (
|
||||
<td key={day} className="text-center no-school-day">
|
||||
<span className="badge-cell bg-info text-dark">NS</span>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
const st = String(admin.cells?.[day]?.status ?? '').toLowerCase()
|
||||
const val = st === 'present' || st === 'absent' || st === 'late' ? st : ''
|
||||
return (
|
||||
<td key={day} className="text-center">
|
||||
<select
|
||||
disabled={!isCurrentYear}
|
||||
className={selectClass(val)}
|
||||
value={val}
|
||||
onChange={(ev) =>
|
||||
onCellChange({
|
||||
userId: uid,
|
||||
date: day,
|
||||
status: ev.target.value,
|
||||
roleName: admin.role ?? 'Admin',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="present">P</option>
|
||||
<option value="absent">A</option>
|
||||
<option value="late">L</option>
|
||||
</select>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="text-center">
|
||||
<strong>{totals.p ?? 0}</strong>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<strong>{totals.a ?? 0}</strong>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<strong>{totals.l ?? 0}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
fetchAttendanceViolationsNotified,
|
||||
fetchAttendanceViolationsPending,
|
||||
saveAttendanceViolationNote,
|
||||
} from '../../api/session'
|
||||
import type { AttendanceViolationStudentRow } from '../../api/types'
|
||||
import {
|
||||
ATTENDANCE_VIOLATIONS_NOTIFIED_PATH,
|
||||
ATTENDANCE_VIOLATIONS_PENDING_PATH,
|
||||
} from './attendancePaths'
|
||||
|
||||
function studentIdOf(st: AttendanceViolationStudentRow): number {
|
||||
return Number(st.student_id ?? st.id ?? 0)
|
||||
}
|
||||
|
||||
function studentNameOf(st: AttendanceViolationStudentRow): string {
|
||||
const n =
|
||||
(st.name as string | undefined) ||
|
||||
(st.student_name as string | undefined) ||
|
||||
`${String(st.firstname ?? st.first_name ?? '')} ${String(st.lastname ?? st.last_name ?? '')}`.trim()
|
||||
const sid = studentIdOf(st)
|
||||
return n || (sid ? `Student #${sid}` : '—')
|
||||
}
|
||||
|
||||
/** Port of `Views/attendance/violations_pending.php` */
|
||||
export function ViolationsPendingPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const filterQuery = useMemo(() => {
|
||||
const s = searchParams.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}, [searchParams])
|
||||
|
||||
const [rows, setRows] = useState<AttendanceViolationStudentRow[]>([])
|
||||
const [debug, setDebug] = useState<Record<string, unknown> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchAttendanceViolationsPending({
|
||||
schoolYear: schoolYear || null,
|
||||
semester: semester || null,
|
||||
})
|
||||
if (!cancelled) {
|
||||
setRows(data.students ?? [])
|
||||
setDebug((data.debug as Record<string, unknown>) ?? null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load violations.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase()
|
||||
if (!s) return rows
|
||||
return rows.filter((r) => studentNameOf(r).toLowerCase().includes(s))
|
||||
}, [rows, q])
|
||||
|
||||
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const p = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
if (sy) p.set('school_year', sy)
|
||||
if (sem) p.set('semester', sem)
|
||||
setSearchParams(p)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid px-0">
|
||||
<h2 className="text-center mt-4 mb-3">Pending Attendance Violations</h2>
|
||||
<div className="d-flex justify-content-center mb-2">
|
||||
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">School Year</label>
|
||||
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">Semester</label>
|
||||
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button type="submit" className="btn btn-sm btn-secondary">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||
|
||||
<div className="row g-0">
|
||||
<div className="col-12">
|
||||
<div className="card border-0 rounded-3 shadow-sm mx-2">
|
||||
<div className="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||
<h5 className="mt-2 mb-0">Pending Attendance Violations</h5>
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||
<span className="badge bg-primary">School Year: {schoolYear || '—'}</span>
|
||||
{semester ? <span className="badge bg-secondary">Semester: {semester}</span> : null}
|
||||
<Link
|
||||
to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`}
|
||||
className="btn btn-outline-dark btn-sm"
|
||||
>
|
||||
View Notified
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-4">
|
||||
<input
|
||||
type="search"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Quick filter…"
|
||||
value={q}
|
||||
onChange={(ev) => setQ(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-wrap gap-3 mb-3 small">
|
||||
<Legend dot="#e6f7ff" label="1 Absence — Auto Email" dark />
|
||||
<Legend dot="#fadb14" label="2 Absences — Team Notify" dark />
|
||||
<Legend dot="#fa8c16" label="3 Absences — Team Notify" />
|
||||
<Legend dot="#ff4d4f" label="4 Absences / Expel / 4 Lates" />
|
||||
<Legend dot="#bae7ff" label="2 Lates — Auto Email" dark />
|
||||
<Legend dot="#002766" label="3 Lates / mixed — Team Notify" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<>
|
||||
<div className="alert alert-info mb-2">No attendance violations found.</div>
|
||||
{debug ? (
|
||||
<div className="alert alert-secondary small">
|
||||
<strong>Debug</strong>
|
||||
<pre className="mb-0 small">{JSON.stringify(debug, null, 2)}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle w-100 small">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Grade</th>
|
||||
<th>Violation</th>
|
||||
<th>Type</th>
|
||||
<th className="text-center">Abs</th>
|
||||
<th className="text-center">Late</th>
|
||||
<th>Last Incident</th>
|
||||
<th>Action</th>
|
||||
<th>Notified?</th>
|
||||
<th>#</th>
|
||||
<th>Parent</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((st, idx) => (
|
||||
<PendingRow key={studentIdOf(st) || idx} st={st} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Legend({ dot, label, dark }: { dot: string; label: string; dark?: boolean }) {
|
||||
return (
|
||||
<span>
|
||||
<span
|
||||
className="badge rounded-pill"
|
||||
style={{ backgroundColor: dot, color: dark ? '#000' : '#fff' }}
|
||||
>
|
||||
|
||||
</span>{' '}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingRow({ st }: { st: AttendanceViolationStudentRow }) {
|
||||
const sid = studentIdOf(st)
|
||||
const name = studentNameOf(st)
|
||||
const grade = String(st.class_name ?? st.class_section_name ?? '')
|
||||
const viTitle = String(st.violation ?? '')
|
||||
const viCode = String(st.violation_code ?? '')
|
||||
const viType = String(st.type ?? '')
|
||||
const viColor = String(st.color ?? '#6c757d')
|
||||
const action = String(st.action ?? '')
|
||||
const absences = (st.absences as unknown[]) ?? []
|
||||
const lates = (st.lates as unknown[]) ?? []
|
||||
const lastDate = st.last_date as string | undefined
|
||||
const incidentYmd = lastDate ? String(lastDate).slice(0, 10) : ''
|
||||
const notified = Boolean(st.is_notified)
|
||||
const notifCnt = Number(st.notif_counter ?? 0)
|
||||
const parentEmail = String(st.parent_email ?? '')
|
||||
const parentName = String(st.parent_name ?? '')
|
||||
const parentLabel = parentName || parentEmail || 'View'
|
||||
|
||||
const isLight = ['#bae7ff', '#e6f7ff', '#fadb14'].includes(viColor.toLowerCase())
|
||||
const badgeCls = `badge rounded-pill ${isLight ? 'text-dark' : 'text-light'}`
|
||||
|
||||
const composeBase = `/app/administrator/attendance/compose-email?student_id=${sid}&code=${encodeURIComponent(viCode)}`
|
||||
const incidentParam = incidentYmd ? `&incident_date=${encodeURIComponent(incidentYmd)}` : ''
|
||||
|
||||
return (
|
||||
<tr style={{ borderLeft: `6px solid ${viColor}` }}>
|
||||
<td>
|
||||
<Link to={`/app/administrator/family?student_id=${sid}`} className="text-decoration-none">
|
||||
{name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{grade}</td>
|
||||
<td>
|
||||
<span className={badgeCls} style={{ backgroundColor: viColor }}>
|
||||
{viTitle}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-capitalize">{viType || '—'}</td>
|
||||
<td className="text-center">{absences.length}</td>
|
||||
<td className="text-center">{lates.length}</td>
|
||||
<td>{incidentYmd ? formatMdY(incidentYmd) : '—'}</td>
|
||||
<td>
|
||||
{action === 'auto_email' ? (
|
||||
<span className="badge bg-primary">Auto Email</span>
|
||||
) : action === 'expel_notify' ? (
|
||||
<span className="badge bg-danger">Expel Notify</span>
|
||||
) : (
|
||||
<span className="badge bg-warning text-dark">Team Notify</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${notified ? 'bg-success' : 'bg-warning text-dark'}`}>
|
||||
{notified ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{notifCnt}</td>
|
||||
<td>
|
||||
<Link to={`/app/administrator/family?student_id=${sid}`} className="text-decoration-none">
|
||||
{parentLabel}
|
||||
</Link>
|
||||
{parentEmail ? (
|
||||
<a className="ms-2" href={`mailto:${parentEmail}`} title="Email parent">
|
||||
<i className="bi bi-envelope" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-nowrap">
|
||||
<Link
|
||||
className="btn btn-sm btn-info"
|
||||
to={`/app/administrator/attendance/view/${sid}?code=${encodeURIComponent(viCode)}${incidentParam}`}
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
{action === 'auto_email' ? (
|
||||
<Link
|
||||
className="btn btn-sm btn-primary ms-1"
|
||||
to={`${composeBase}&variant=default${incidentParam}`}
|
||||
>
|
||||
Compose Auto Email
|
||||
</Link>
|
||||
) : (
|
||||
<span className="btn-group ms-1">
|
||||
<Link className="btn btn-sm btn-success" to={`${composeBase}&variant=answered${incidentParam}`}>
|
||||
Answered
|
||||
</Link>
|
||||
<Link className="btn btn-sm btn-warning" to={`${composeBase}&variant=no_answer${incidentParam}`}>
|
||||
No Answer
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/** Port of `Views/attendance/violations_notified.php` */
|
||||
export function ViolationsNotifiedPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const filterQuery = useMemo(() => {
|
||||
const s = searchParams.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}, [searchParams])
|
||||
|
||||
const [rows, setRows] = useState<AttendanceViolationStudentRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [noteModal, setNoteModal] = useState<AttendanceViolationStudentRow | null>(null)
|
||||
const [noteText, setNoteText] = useState('')
|
||||
const [savingNote, setSavingNote] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchAttendanceViolationsNotified({
|
||||
schoolYear: schoolYear || null,
|
||||
semester: semester || null,
|
||||
})
|
||||
if (!cancelled) setRows(data.students ?? [])
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const p = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
if (sy) p.set('school_year', sy)
|
||||
if (sem) p.set('semester', sem)
|
||||
setSearchParams(p)
|
||||
}
|
||||
|
||||
async function submitNote(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!noteModal) return
|
||||
setSavingNote(true)
|
||||
try {
|
||||
const sid = studentIdOf(noteModal)
|
||||
const code = String(noteModal.reason ?? noteModal.violation_code ?? '')
|
||||
const lastDate = noteModal.last_date as string | undefined
|
||||
const incidentYmd = lastDate ? String(lastDate).slice(0, 10) : ''
|
||||
await saveAttendanceViolationNote({
|
||||
student_id: String(sid),
|
||||
code,
|
||||
incident_date: incidentYmd,
|
||||
note: noteText,
|
||||
return_url: typeof window !== 'undefined' ? window.location.href : '',
|
||||
})
|
||||
setNoteModal(null)
|
||||
setNoteText('')
|
||||
} catch {
|
||||
setError('Unable to save note.')
|
||||
} finally {
|
||||
setSavingNote(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid px-0">
|
||||
<h2 className="text-center mt-4 mb-3">Notified Attendance Violations</h2>
|
||||
<div className="d-flex justify-content-center mb-2">
|
||||
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">School Year</label>
|
||||
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">Semester</label>
|
||||
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button type="submit" className="btn btn-sm btn-secondary">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||
|
||||
<div className="card border-0 rounded-3 shadow-sm mx-2">
|
||||
<div className="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||
<h5 className="mt-2 mb-0">Notified Attendance Violations</h5>
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||
<span className="badge bg-primary">School Year: {schoolYear || '—'}</span>
|
||||
{semester ? <span className="badge bg-secondary">Semester: {semester}</span> : null}
|
||||
<Link to={`${ATTENDANCE_VIOLATIONS_PENDING_PATH}${filterQuery}`} className="btn btn-outline-dark btn-sm">
|
||||
View Pending
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="alert alert-info mb-0">No notified violations found.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle w-100 small">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Grade</th>
|
||||
<th>Last Incident</th>
|
||||
<th>Reason</th>
|
||||
<th>Note</th>
|
||||
<th>Parent</th>
|
||||
<th>Notified On</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((st, idx) => (
|
||||
<NotifiedRow
|
||||
key={studentIdOf(st) || idx}
|
||||
st={st}
|
||||
onEditNote={() => {
|
||||
setNoteModal(st)
|
||||
setNoteText(String(st.note ?? ''))
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{noteModal ? (
|
||||
<div className="modal show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,0.4)' }}>
|
||||
<div className="modal-dialog">
|
||||
<form className="modal-content" onSubmit={submitNote}>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Comment / Note</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={() => setNoteModal(null)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={5}
|
||||
required
|
||||
value={noteText}
|
||||
onChange={(ev) => setNoteText(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="submit" className="btn btn-primary" disabled={savingNote}>
|
||||
Save Note
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => setNoteModal(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotifiedRow({
|
||||
st,
|
||||
onEditNote,
|
||||
}: {
|
||||
st: AttendanceViolationStudentRow
|
||||
onEditNote: () => void
|
||||
}) {
|
||||
const sid = studentIdOf(st)
|
||||
const name = studentNameOf(st)
|
||||
const grade = String(st.class_section_name ?? st.class_name ?? '')
|
||||
const lastDate = st.last_date ?? st.date
|
||||
const incidentYmd = lastDate ? String(lastDate).slice(0, 10) : ''
|
||||
const reason = String(st.reason ?? '')
|
||||
const note = String(st.note ?? '')
|
||||
const parentName = String(st.parent_name ?? '')
|
||||
const notifiedAt = st.updated_at ?? st.date
|
||||
const code = String(st.reason ?? st.violation_code ?? '')
|
||||
const viColor = String(st.color ?? '#0d6efd')
|
||||
|
||||
return (
|
||||
<tr style={{ borderLeft: `6px solid ${viColor}` }}>
|
||||
<td>
|
||||
<Link to={`/app/administrator/family?student_id=${sid}`} className="text-decoration-none">
|
||||
{name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{grade}</td>
|
||||
<td>{incidentYmd ? formatMdY(incidentYmd) : '—'}</td>
|
||||
<td>{reason || '—'}</td>
|
||||
<td style={{ maxWidth: 280 }}>
|
||||
{note ? (
|
||||
<span className="small">{note.length > 120 ? `${note.slice(0, 120)}…` : note}</span>
|
||||
) : null}
|
||||
<button type="button" className="btn btn-sm btn-outline-primary ms-1" onClick={onEditNote}>
|
||||
{note ? 'Edit note' : 'Add note'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{parentName ? (
|
||||
<Link to={`/app/administrator/family?student_id=${sid}`} className="text-decoration-none">
|
||||
{parentName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{notifiedAt ? formatMdY(String(notifiedAt).slice(0, 10)) : '—'}</td>
|
||||
<td>
|
||||
<Link
|
||||
className="btn btn-sm btn-info"
|
||||
to={`/app/administrator/attendance/view/${sid}?code=${encodeURIComponent(code)}&incident_date=${encodeURIComponent(incidentYmd)}&include_notified=1`}
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function formatMdY(ymd: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
||||
const [y, m, d] = ymd.split('-')
|
||||
return `${m}-${d}-${y}`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Monthly teacher/staff attendance (JWT `GET /api/v1/attendance/staff/month-overview`, POST `month-save`). */
|
||||
export const TEACHER_ATTENDANCE_MONTH_PATH = '/app/admin/teacher-attendance/month'
|
||||
|
||||
/** Attendance violation queues (JWT `GET /api/v1/attendance/violations/pending|notified`, POST `save-note`, etc.). */
|
||||
export const ATTENDANCE_VIOLATIONS_PATH = '/app/attendance/violations'
|
||||
export const ATTENDANCE_VIOLATIONS_PENDING_PATH = `${ATTENDANCE_VIOLATIONS_PATH}/pending`
|
||||
export const ATTENDANCE_VIOLATIONS_NOTIFIED_PATH = `${ATTENDANCE_VIOLATIONS_PATH}/notified`
|
||||
|
||||
/** Early dismissals list + add form (JWT `GET/POST /api/v1/attendance/early-dismissals`, `student-options`, signature upload). */
|
||||
export const EARLY_DISMISSALS_PATH = '/app/attendance/early-dismissals'
|
||||
export const EARLY_DISMISSALS_NEW_PATH = `${EARLY_DISMISSALS_PATH}/new`
|
||||
@@ -0,0 +1,14 @@
|
||||
export { AdministratorAbsencePage } from './AdministratorAbsencePage'
|
||||
export { AdminsAttendanceFormPage } from './AdminsAttendanceFormPage'
|
||||
export { AttendanceTrackingPage } from './AttendanceTrackingPage'
|
||||
export { ComposeAttendanceEmailPage } from './ComposeAttendanceEmailPage'
|
||||
export { EarlyDismissalsAddPage } from './EarlyDismissalsAddPage'
|
||||
export { EarlyDismissalsPage } from './EarlyDismissalsPage'
|
||||
export { AttendanceEditModalBody } from './AttendanceEditModal'
|
||||
export { ParentAttendanceReportsAdminPage } from './ParentAttendanceReportsAdminPage'
|
||||
export { TeacherAttendanceFormPage } from './TeacherAttendanceFormPage'
|
||||
export { TeacherAttendanceMonthPage } from './TeacherAttendanceMonthPage'
|
||||
export { AttendanceStudentViolationsViewPage } from './AttendanceStudentViolationsViewPage'
|
||||
export { AttendanceViolationsHubPage } from './AttendanceViolationsHubPage'
|
||||
export { ViolationsNotifiedPage, ViolationsPendingPage } from './ViolationsPages'
|
||||
export { AttendanceTemplatesIndexPage } from './AttendanceTemplatesIndexPage'
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session'
|
||||
import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types'
|
||||
|
||||
function adjustQty(input: HTMLInputElement, delta: number) {
|
||||
const curr = parseInt(input.value, 10) || 0
|
||||
input.value = String(curr + delta)
|
||||
}
|
||||
|
||||
function defaultSchoolYear() {
|
||||
const y = new Date().getFullYear()
|
||||
return `${y}-${y + 1}`
|
||||
}
|
||||
|
||||
export function ClassPrepListPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? defaultSchoolYear()
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const [data, setData] = useState<ClassPrepIndexResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [savingId, setSavingId] = useState<string | null>(null)
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||
|
||||
const yearOptions = useMemo(() => {
|
||||
const out: string[] = []
|
||||
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
|
||||
return out
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchClassPrepIndex({
|
||||
school_year: schoolYear,
|
||||
semester: semester || undefined,
|
||||
})
|
||||
.then((d) => {
|
||||
if (!cancelled) setData(d)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to load class preparation data.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
const onFilterSubmit = useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
const next = new URLSearchParams()
|
||||
if (sy) next.set('school_year', sy)
|
||||
if (sem) next.set('semester', sem)
|
||||
setSearchParams(next)
|
||||
},
|
||||
[setSearchParams],
|
||||
)
|
||||
|
||||
async function onSaveAdjustment(prep: ClassPrepSectionRow, form: HTMLFormElement) {
|
||||
setSavingId(String(prep.class_section_id))
|
||||
setSaveMsg(null)
|
||||
const fd = new FormData(form)
|
||||
const adjustments: Record<string, number> = {}
|
||||
fd.forEach((v, k) => {
|
||||
if (k.startsWith('adj__')) {
|
||||
const name = k.slice(5)
|
||||
adjustments[name] = parseInt(String(v), 10) || 0
|
||||
}
|
||||
})
|
||||
try {
|
||||
await saveClassPrepAdjustment({
|
||||
class_section_id: prep.class_section_id,
|
||||
school_year: schoolYear,
|
||||
semester: semester || undefined,
|
||||
adjustments,
|
||||
})
|
||||
setSaveMsg('Saved.')
|
||||
const fresh = await fetchClassPrepIndex({
|
||||
school_year: schoolYear,
|
||||
semester: semester || undefined,
|
||||
})
|
||||
setData(fresh)
|
||||
} catch (e: unknown) {
|
||||
setSaveMsg(e instanceof ApiHttpError ? e.message : 'Save failed.')
|
||||
} finally {
|
||||
setSavingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const printBase = `/app/administrator/class-prep/print`
|
||||
const semQuery = semester ? `?semester=${encodeURIComponent(semester)}` : ''
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<div className="wrapper">
|
||||
<h2 className="text-center mt-4 mb-3">Class Preparation</h2>
|
||||
|
||||
<form onSubmit={onFilterSubmit} className="mb-4 d-flex align-items-center gap-2 flex-wrap">
|
||||
<label htmlFor="school_year" className="form-label mb-0">
|
||||
School Year:
|
||||
</label>
|
||||
<select
|
||||
name="school_year"
|
||||
id="school_year"
|
||||
className="form-select form-select-sm w-auto"
|
||||
defaultValue={schoolYear}
|
||||
>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label htmlFor="semester" className="form-label mb-0">
|
||||
Semester:
|
||||
</label>
|
||||
<select
|
||||
name="semester"
|
||||
id="semester"
|
||||
className="form-select form-select-sm w-auto"
|
||||
defaultValue={semester}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="Fall">Fall</option>
|
||||
<option value="Spring">Spring</option>
|
||||
</select>
|
||||
<button type="submit" className="btn btn-sm btn-primary">
|
||||
Calculate Items
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : null}
|
||||
|
||||
{saveMsg ? (
|
||||
<div className="alert alert-info py-2" role="status">
|
||||
{saveMsg}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="row g-3">
|
||||
{(data?.prep_results ?? []).map((prep) => {
|
||||
const adjMap = prep.adjustments ?? {}
|
||||
return (
|
||||
<div key={String(prep.class_section_id)} className="col-12 col-lg-6">
|
||||
<div className="card shadow-sm border-0 h-100 classprep-card">
|
||||
<div className="card-header d-flex justify-content-between align-items-center bg-light border-0">
|
||||
<div>
|
||||
<div className="fw-semibold mb-1">{prep.class_section}</div>
|
||||
{prep.needs_print ? (
|
||||
<span className="badge bg-danger">Updated since last print</span>
|
||||
) : (
|
||||
<span className="badge bg-success">Up-to-date</span>
|
||||
)}
|
||||
{prep.last_printed_at ? (
|
||||
<small className="text-muted ms-2">Last printed: {prep.last_printed_at}</small>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="small text-muted text-end">
|
||||
Students:
|
||||
<br />
|
||||
<span className="badge bg-secondary">{prep.student_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-7">
|
||||
<div className="fw-semibold mb-2">Prep Items</div>
|
||||
<ul className="list-group list-group-flush small border rounded overflow-hidden">
|
||||
{Object.entries(prep.prep_items ?? {}).map(([item, qty]) => (
|
||||
<li
|
||||
key={item}
|
||||
className={`list-group-item d-flex justify-content-between align-items-center ${
|
||||
Number(qty) === 0 ? 'text-muted classprep-muted-soft' : ''
|
||||
}`}
|
||||
>
|
||||
<span>{item}</span>
|
||||
<span
|
||||
className={`badge rounded-pill ${
|
||||
Number(qty) > 0 ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{Number(qty)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-md-5">
|
||||
<div className="fw-semibold mb-2">Adjustments (Tables)</div>
|
||||
<form
|
||||
className="d-grid gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void onSaveAdjustment(prep, e.currentTarget)
|
||||
}}
|
||||
>
|
||||
{Object.entries(adjMap).map(([item, value]) => (
|
||||
<div key={item}>
|
||||
<label className="form-label mb-1 small text-muted d-block">{item}</label>
|
||||
<div className="input-group input-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={(ev) => {
|
||||
const input = ev.currentTarget.parentElement?.querySelector(
|
||||
'input[type="number"]',
|
||||
) as HTMLInputElement | null
|
||||
if (input) adjustQty(input, -1)
|
||||
}}
|
||||
aria-label={`Decrease ${item}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control text-center"
|
||||
name={`adj__${item}`}
|
||||
defaultValue={Number(value)}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={(ev) => {
|
||||
const input = ev.currentTarget.parentElement?.querySelector(
|
||||
'input[type="number"]',
|
||||
) as HTMLInputElement | null
|
||||
if (input) adjustQty(input, 1)
|
||||
}}
|
||||
aria-label={`Increase ${item}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="d-flex justify-content-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={savingId === String(prep.class_section_id)}
|
||||
>
|
||||
{savingId === String(prep.class_section_id) ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-footer bg-transparent border-0 d-flex justify-content-end">
|
||||
<Link
|
||||
to={`${printBase}/${encodeURIComponent(String(prep.class_section_id))}/${encodeURIComponent(schoolYear)}${semQuery}`}
|
||||
className={`btn btn-lg ${prep.needs_print ? 'btn-danger' : 'btn-success'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Print
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<h5 className="mb-3">Totals for All Used Items</h5>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th className="text-end">Total Needed</th>
|
||||
<th className="text-end">In Inventory</th>
|
||||
<th className="text-end">Short By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data?.total_needed ?? {}).map(([item, needed]) => {
|
||||
const have = Number((data?.available ?? {})[item] ?? 0)
|
||||
const short = Math.max(0, Number(needed) - have)
|
||||
return (
|
||||
<tr key={item} className={short > 0 ? 'table-warning' : ''}>
|
||||
<td>{item}</td>
|
||||
<td className="text-end">{Number(needed)}</td>
|
||||
<td className="text-end">{have}</td>
|
||||
<td
|
||||
className={`text-end ${short > 0 ? 'text-danger fw-bold' : 'text-success'}`}
|
||||
>
|
||||
{short}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data?.shortages && Object.keys(data.shortages).length > 0 ? (
|
||||
<div className="alert alert-danger mt-3">
|
||||
<strong>Shortages Detected</strong>
|
||||
<ul className="mb-0">
|
||||
{Object.entries(data.shortages).map(([item, short]) => (
|
||||
<li key={item}>
|
||||
{item}: short by {Number(short)} pcs
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : data && Object.keys(data.total_needed ?? {}).length > 0 ? (
|
||||
<div className="alert alert-success mt-3">All items are available in stock.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.classprep-muted-soft { opacity: 0.55; }
|
||||
.classprep-card { border-radius: 12px; }
|
||||
.classprep-card .card-header {
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
}
|
||||
.classprep-card .card-footer {
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.classprep-print-root {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.classprep-print-root .prep-label {
|
||||
border: 2px solid #000;
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.classprep-print-root .prep-label h2 {
|
||||
font-size: clamp(2rem, 8vw, 5rem);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.classprep-print-root .prep-label ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
font-size: clamp(1.25rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
@media print {
|
||||
.management-app > header,
|
||||
.mgmt-sidebar,
|
||||
.mgmt-sidebar-toggle,
|
||||
.mgmt-sidebar-backdrop,
|
||||
.footer-back {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.management-main {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.classprep-print-root {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.classprep-print-root .prep-label {
|
||||
border: none;
|
||||
page-break-after: always;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchClassPrepPrintPayload } from '../../api/session'
|
||||
import './ClassPrepPrintPage.css'
|
||||
|
||||
const PREFERRED_ORDER = [
|
||||
'Grade Box',
|
||||
'Large Table',
|
||||
'Regular Chair',
|
||||
'Small Chair',
|
||||
'Small Table',
|
||||
'Teacher Chair',
|
||||
'Trash Bin',
|
||||
'White Board',
|
||||
]
|
||||
|
||||
function orderPrepItems(prepItems: Record<string, number>): [string, number][] {
|
||||
const ordered: [string, number][] = []
|
||||
for (const k of PREFERRED_ORDER) {
|
||||
if (Object.prototype.hasOwnProperty.call(prepItems, k)) {
|
||||
ordered.push([k, prepItems[k]!])
|
||||
}
|
||||
}
|
||||
for (const [k, v] of Object.entries(prepItems)) {
|
||||
if (!ordered.some(([ok]) => ok === k)) ordered.push([k, v])
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
export function ClassPrepPrintPage() {
|
||||
const { sectionId, schoolYear } = useParams<{ sectionId: string; schoolYear: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
const semester = searchParams.get('semester') ?? undefined
|
||||
|
||||
const [title, setTitle] = useState<string>('Class')
|
||||
const [items, setItems] = useState<Record<string, number>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const printedRef = useRef(false)
|
||||
|
||||
const decoded = useMemo(
|
||||
() => ({
|
||||
sectionId: sectionId ? decodeURIComponent(sectionId) : '',
|
||||
schoolYear: schoolYear ? decodeURIComponent(schoolYear) : '',
|
||||
}),
|
||||
[sectionId, schoolYear],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
printedRef.current = false
|
||||
}, [decoded.sectionId, decoded.schoolYear, semester])
|
||||
|
||||
useEffect(() => {
|
||||
if (!decoded.sectionId || !decoded.schoolYear) return
|
||||
let cancelled = false
|
||||
setReady(false)
|
||||
fetchClassPrepPrintPayload({
|
||||
sectionId: decoded.sectionId,
|
||||
schoolYear: decoded.schoolYear,
|
||||
semester,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setTitle(res.class_section?.trim() || `Section ${decoded.sectionId}`)
|
||||
setItems(res.prep_items ?? {})
|
||||
setReady(true)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load print data.')
|
||||
setReady(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [decoded.sectionId, decoded.schoolYear, semester])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || error || printedRef.current) return
|
||||
printedRef.current = true
|
||||
const t = window.setTimeout(() => window.print(), 400)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [ready, error])
|
||||
|
||||
const rows = orderPrepItems(items).filter(([, q]) => Number(q) > 0)
|
||||
|
||||
return (
|
||||
<div className="classprep-print-root">
|
||||
{error ? (
|
||||
<p className="text-danger p-4">{error}</p>
|
||||
) : (
|
||||
<div className="prep-label">
|
||||
<h2>Grade {title}</h2>
|
||||
<ul>
|
||||
{rows.length === 0 ? (
|
||||
<li>No items needed</li>
|
||||
) : (
|
||||
rows.map(([name, qty]) => (
|
||||
<li key={name}>
|
||||
<strong>{qty}</strong> {name}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
fetchClassProgressGroups,
|
||||
fetchClassProgressMeta,
|
||||
fetchClassSections,
|
||||
} from '../../api/session'
|
||||
import type { ClassProgressGroupRow } from '../../api/types'
|
||||
import { ClassProgressUnitDisplay } from './ClassProgressUnitDisplay'
|
||||
import { formatProgressDate } from './classProgressUtils'
|
||||
|
||||
/** CI `admin/class_progress_list.php` — accordion by class section, filters, submission badges when API provides stats. */
|
||||
export function ClassProgressListPage() {
|
||||
const [filters, setFilters] = useState({ weekStart: '', weekEnd: '', classSectionId: '', status: '' })
|
||||
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
|
||||
const [statusOptions, setStatusOptions] = useState<Record<string, string>>({})
|
||||
const [classSections, setClassSections] = useState<
|
||||
Array<{ class_section_id?: number | null; class_section_name?: string | null }>
|
||||
>([])
|
||||
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
/** Server may later expose `low_progress_section_ids`; until then keep empty (CI disables button). */
|
||||
const lowProgressSectionIds: number[] = []
|
||||
|
||||
const lowProgressQuery = lowProgressSectionIds.join(',')
|
||||
const lowProgressUrl =
|
||||
lowProgressQuery === ''
|
||||
? '/app/administrator/teacher-submissions'
|
||||
: `/app/administrator/teacher-submissions?low_progress_sections=${encodeURIComponent(lowProgressQuery)}`
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [meta, sections, groups] = await Promise.all([
|
||||
fetchClassProgressMeta(),
|
||||
fetchClassSections({ perPage: 200 }),
|
||||
fetchClassProgressGroups({
|
||||
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
|
||||
status: filters.status || null,
|
||||
weekStart: filters.weekStart || null,
|
||||
weekEnd: filters.weekEnd || null,
|
||||
perPage: 200,
|
||||
}),
|
||||
])
|
||||
setSubjectSections(meta.data?.subject_sections ?? {})
|
||||
setStatusOptions(meta.data?.status_options ?? {})
|
||||
setClassSections(sections.data?.sections ?? [])
|
||||
setItems(groups.data?.items ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load class progress.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const groupsBySection = useMemo(() => {
|
||||
const m = new Map<number, ClassProgressGroupRow[]>()
|
||||
for (const item of items) {
|
||||
const sid = Number(item.class_section_id ?? 0)
|
||||
if (!sid) continue
|
||||
const arr = m.get(sid) ?? []
|
||||
arr.push(item)
|
||||
m.set(sid, arr)
|
||||
}
|
||||
return m
|
||||
}, [items])
|
||||
|
||||
const subjectCount = Object.keys(subjectSections).length
|
||||
|
||||
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
|
||||
const reports = group.reports ?? {}
|
||||
const teacherCounts: Record<string, number> = {}
|
||||
const teacherLatest: Record<string, string> = {}
|
||||
for (const report of Object.values(reports)) {
|
||||
const name = String(report?.teacher_name ?? '').trim()
|
||||
if (!name) continue
|
||||
teacherCounts[name] = (teacherCounts[name] ?? 0) + 1
|
||||
const stamp = String(report?.updated_at ?? report?.created_at ?? '')
|
||||
if (stamp !== '' && (!teacherLatest[name] || stamp > teacherLatest[name])) {
|
||||
teacherLatest[name] = stamp
|
||||
}
|
||||
}
|
||||
if (!Object.keys(teacherCounts).length) return '—'
|
||||
let bestName = ''
|
||||
let bestCount = -1
|
||||
let bestStamp = ''
|
||||
for (const [name, count] of Object.entries(teacherCounts)) {
|
||||
const stamp = teacherLatest[name] ?? ''
|
||||
if (count > bestCount || (count === bestCount && stamp > bestStamp)) {
|
||||
bestName = name
|
||||
bestCount = count
|
||||
bestStamp = stamp
|
||||
}
|
||||
}
|
||||
return bestName || '—'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.admin-progress-table thead th {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
z-index: auto !important;
|
||||
background: #f8f9fa;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
}
|
||||
.admin-progress-table td { vertical-align: top; }
|
||||
.bg-orange { background-color: #fd7e14 !important; }
|
||||
`}</style>
|
||||
<div className="container py-4">
|
||||
<div className="mb-4 pb-3 border-bottom">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 className="mb-0">Class Progress Reports</h3>
|
||||
<div className="text-muted">Filter by week, class, and status</div>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
className={`btn btn-sm btn-outline-warning${lowProgressSectionIds.length === 0 ? ' disabled' : ''}`}
|
||||
to={lowProgressUrl}
|
||||
aria-disabled={lowProgressSectionIds.length === 0}
|
||||
tabIndex={lowProgressSectionIds.length === 0 ? -1 : undefined}
|
||||
onClick={(e) => {
|
||||
if (lowProgressSectionIds.length === 0) e.preventDefault()
|
||||
}}
|
||||
>
|
||||
Teachers < 50%
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<form
|
||||
className="card shadow-sm mb-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
value={filters.weekStart}
|
||||
onChange={(e) => setFilters((c) => ({ ...c, weekStart: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">To</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
value={filters.weekEnd}
|
||||
onChange={(e) => setFilters((c) => ({ ...c, weekEnd: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Class/Section</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.classSectionId}
|
||||
onChange={(e) => setFilters((c) => ({ ...c, classSectionId: e.target.value }))}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{classSections.map((section) => (
|
||||
<option key={String(section.class_section_id ?? '')} value={section.class_section_id ?? ''}>
|
||||
{section.class_section_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters((c) => ({ ...c, status: e.target.value }))}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{Object.entries(statusOptions).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilters({ weekStart: '', weekEnd: '', classSectionId: '', status: '' })
|
||||
window.setTimeout(() => void load(), 0)
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card shadow-sm mt-5">
|
||||
<div className="card-body pt-4">
|
||||
{classSections.length === 0 ? (
|
||||
<div className="text-center text-muted py-4">No class sections found.</div>
|
||||
) : (
|
||||
<div className="accordion" id="adminProgressAccordion">
|
||||
{classSections.map((cs) => {
|
||||
const sectionId = Number(cs.class_section_id ?? 0)
|
||||
const sectionName = cs.class_section_name ?? 'Unknown Section'
|
||||
const sectionGroups = groupsBySection.get(sectionId) ?? []
|
||||
const hasReports = sectionGroups.length > 0
|
||||
const collapseId = `progress-section-${sectionId}`
|
||||
const headingId = `progress-heading-${sectionId}`
|
||||
const submissionLabel = 'Submitted: N/A'
|
||||
const badgeClass = 'bg-secondary'
|
||||
return (
|
||||
<div className="accordion-item mb-2" key={sectionId || sectionName}>
|
||||
<h2 className="accordion-header" id={headingId}>
|
||||
<button
|
||||
className="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target={`#${collapseId}`}
|
||||
aria-expanded="false"
|
||||
aria-controls={collapseId}
|
||||
>
|
||||
{sectionName}
|
||||
<span className={`badge ${badgeClass} ms-2`}>{submissionLabel}</span>
|
||||
<span className="badge bg-info text-dark ms-2">Units: {subjectCount}</span>
|
||||
{hasReports ? (
|
||||
<span className="badge bg-secondary ms-2">{sectionGroups.length} weeks</span>
|
||||
) : null}
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id={collapseId}
|
||||
className="accordion-collapse collapse"
|
||||
aria-labelledby={headingId}
|
||||
data-bs-parent="#adminProgressAccordion"
|
||||
>
|
||||
<div className="accordion-body">
|
||||
{!hasReports ? (
|
||||
<div className="text-muted">No reports found for this section.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table
|
||||
className="table table-hover align-middle mb-0 admin-progress-table no-mgmt-sticky"
|
||||
data-no-mgmt-sticky
|
||||
>
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Week</th>
|
||||
<th>Subjects</th>
|
||||
<th>Teacher</th>
|
||||
<th className="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sectionGroups.map((group, groupIndex) => {
|
||||
const reports = group.reports ?? {}
|
||||
const reportValues = Object.values(reports)
|
||||
const firstReport = reportValues[0]
|
||||
const exampleId = firstReport?.id
|
||||
const reportKey = reportValues
|
||||
.map((report) => String(report?.id ?? report?.subject ?? report?.teacher_name ?? ''))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join('-')
|
||||
const weekLabel = `${formatProgressDate(group.week_start)} – ${formatProgressDate(group.week_end)}`
|
||||
return (
|
||||
<tr
|
||||
key={`${sectionId}-${group.week_start}-${group.week_end}-${reportKey || exampleId || 'group'}-${groupIndex}`}
|
||||
>
|
||||
<td>
|
||||
<div className="fw-semibold">{weekLabel}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{Object.entries(subjectSections).map(([slug, section]) => {
|
||||
const subjectName = section.db_subject ?? section.label ?? slug
|
||||
const report = reports[subjectName]
|
||||
const statusTag = report
|
||||
? (report.status_label ?? 'Unknown')
|
||||
: 'No entry'
|
||||
const badgeClassInner = report ? 'bg-secondary' : 'bg-light text-muted'
|
||||
return (
|
||||
<div key={slug} className="border rounded-3 p-2">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<strong className="small mb-0">
|
||||
{section.label ?? subjectName}
|
||||
</strong>
|
||||
<span className={`badge ${badgeClassInner}`}>{statusTag}</span>
|
||||
</div>
|
||||
<div className="small">
|
||||
{report ? (
|
||||
<ClassProgressUnitDisplay
|
||||
unitTitle={report.unit_title}
|
||||
isQuran={subjectName === 'Quran/Arabic'}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted">No submission</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td>{teacherLabelForGroup(group)}</td>
|
||||
<td className="text-end">
|
||||
{exampleId ? (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
type="button"
|
||||
onClick={() => navigate(`/app/admin/progress/view/${exampleId}`)}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { splitUnitTitle } from './classProgressUtils'
|
||||
|
||||
/** CI `admin/partials/class_progress_unit_display.php` */
|
||||
export function ClassProgressUnitDisplay({
|
||||
unitTitle,
|
||||
isQuran,
|
||||
compact,
|
||||
}: {
|
||||
unitTitle?: string | null
|
||||
isQuran?: boolean
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { curriculum, custom } = splitUnitTitle(unitTitle)
|
||||
const labelCurriculum = isQuran ? 'Surah / curriculum' : 'Unit / chapter'
|
||||
const labelCustom = isQuran ? 'Custom Surah / Arabic' : 'Custom subject(s)'
|
||||
|
||||
if (!String(unitTitle ?? '').trim()) return <span className="text-muted">-</span>
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
{custom.length > 0 ? (
|
||||
<div className="small">
|
||||
<span className="badge text-bg-info me-1">{isQuran ? 'Custom' : 'Custom subject'}</span>
|
||||
{custom.join(', ')}
|
||||
</div>
|
||||
) : null}
|
||||
{curriculum.length > 0 ? (
|
||||
<div className={`small text-muted${custom.length > 0 ? ' mt-1' : ''}`}>{curriculum.join(' · ')}</div>
|
||||
) : null}
|
||||
{custom.length === 0 && curriculum.length === 0 ? (
|
||||
<div className="small text-muted">{String(unitTitle)}</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{curriculum.length > 0 ? (
|
||||
<div className="mb-2">
|
||||
<strong>{labelCurriculum}:</strong> {curriculum.join(' ; ')}
|
||||
</div>
|
||||
) : null}
|
||||
{custom.length > 0 ? (
|
||||
<div className="mb-2">
|
||||
<strong>{labelCustom}:</strong> {custom.join(' ; ')}
|
||||
</div>
|
||||
) : null}
|
||||
{curriculum.length === 0 && custom.length === 0 ? (
|
||||
<div className="mb-2">
|
||||
<strong>{labelCurriculum}:</strong> {String(unitTitle)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { apiUrl } from '../../lib/apiOrigin'
|
||||
import { fetchClassProgressDetail, fetchClassProgressMeta } from '../../api/session'
|
||||
import type { ClassProgressReportRow } from '../../api/types'
|
||||
import { ClassProgressUnitDisplay } from './ClassProgressUnitDisplay'
|
||||
import { FLAG_LABELS, formatProgressDate } from './classProgressUtils'
|
||||
|
||||
function attachmentHref(att: {
|
||||
id?: number
|
||||
download_url?: string | null
|
||||
legacy?: boolean | number | null
|
||||
}) {
|
||||
if (att.download_url) return att.download_url
|
||||
const id = att.id
|
||||
if (!id) return '#'
|
||||
const legacy = Boolean(att.legacy)
|
||||
return legacy
|
||||
? apiUrl(`/admin/progress/attachment/${id}`)
|
||||
: apiUrl(`/admin/progress/attachment-file/${id}`)
|
||||
}
|
||||
|
||||
/** CI `admin/class_progress_view.php` */
|
||||
export function ClassProgressViewPage() {
|
||||
const { id } = useParams()
|
||||
const reportId = Number(id)
|
||||
const [report, setReport] = useState<ClassProgressReportRow | null>(null)
|
||||
const [weeklyReports, setWeeklyReports] = useState<ClassProgressReportRow[]>([])
|
||||
const [subjectSections, setSubjectSections] = useState<Record<string, { label?: string; db_subject?: string }>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportId) return
|
||||
;(async () => {
|
||||
try {
|
||||
const [detail, meta] = await Promise.all([
|
||||
fetchClassProgressDetail(reportId),
|
||||
fetchClassProgressMeta(),
|
||||
])
|
||||
setReport(detail.data?.report ?? null)
|
||||
setWeeklyReports(detail.data?.weekly_reports ?? [])
|
||||
setSubjectSections(meta.data?.subject_sections ?? {})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load progress report.')
|
||||
}
|
||||
})()
|
||||
}, [reportId])
|
||||
|
||||
const reportsBySubject = useMemo(
|
||||
() => Object.fromEntries(weeklyReports.map((entry) => [String(entry.subject ?? ''), entry])),
|
||||
[weeklyReports],
|
||||
)
|
||||
|
||||
const attachments = weeklyReports.flatMap((entry) =>
|
||||
(entry.attachments ?? []).map((attachment) => ({
|
||||
subject: entry.subject ?? 'Attachment',
|
||||
attachment,
|
||||
report_id: entry.id,
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container py-4">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{report ? (
|
||||
<>
|
||||
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h3 className="mb-0">Progress Report</h3>
|
||||
<div className="text-muted">
|
||||
{report.class_section_name ?? '-'} • {formatProgressDate(report.week_start)} →{' '}
|
||||
{formatProgressDate(report.week_end)}
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/app/admin/progress" className="btn btn-outline-secondary">
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-lg-8">
|
||||
{Object.entries(subjectSections).map(([slug, section]) => {
|
||||
const subjectName = section.db_subject ?? section.label ?? slug
|
||||
const isQuran = subjectName === 'Quran/Arabic'
|
||||
const homeworkLabel = isQuran ? 'Arabic Practice / Homework' : 'Assigned Homework'
|
||||
const entry = reportsBySubject[subjectName]
|
||||
return (
|
||||
<div className="card shadow-sm mb-3" key={slug}>
|
||||
<div className="card-header bg-white d-flex align-items-center justify-content-between">
|
||||
<strong className="mb-0">{section.label ?? subjectName}</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{!entry ? (
|
||||
<div className="text-muted">No entry submitted for this subject this week.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<ClassProgressUnitDisplay
|
||||
unitTitle={entry.unit_title}
|
||||
isQuran={isQuran}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
{String(entry.materials ?? '').trim() ? (
|
||||
<div className="mb-2">
|
||||
<strong>Materials:</strong> {entry.materials}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-3">
|
||||
<strong>Activities</strong>
|
||||
<div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{entry.covered || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<strong>{homeworkLabel}:</strong>
|
||||
<div className="mt-1" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{entry.homework || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{(entry.attachments ?? []).length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<strong>Attachments:</strong>
|
||||
<div className="d-flex flex-column gap-2 mt-2">
|
||||
{(entry.attachments ?? []).map((att) => {
|
||||
const linkId = att.id ?? entry.id
|
||||
return (
|
||||
<a
|
||||
key={String(att.id ?? att.name ?? '')}
|
||||
className="btn btn-sm btn-outline-secondary text-start"
|
||||
href={attachmentHref({ ...att, id: linkId })}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{att.name ?? 'Attachment'}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="col-lg-4">
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-header bg-white">
|
||||
<strong>Summary</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2">
|
||||
<strong>Teacher:</strong> {String(report.teacher_name ?? '').trim() || '-'}
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<strong>Submitted:</strong> {formatProgressDate(report.created_at, true)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong> {report.status_label ?? 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(report.flags ?? []).length > 0 ? (
|
||||
<div className="card shadow-sm mb-3">
|
||||
<div className="card-header bg-white">
|
||||
<strong>Checklist</strong>
|
||||
</div>
|
||||
<div className="card-body d-flex flex-wrap gap-2">
|
||||
{(report.flags ?? []).map((flag) => (
|
||||
<span key={flag} className="badge bg-light text-dark border">
|
||||
{FLAG_LABELS[flag] ?? flag.replace(/_/g, ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header bg-white">
|
||||
<strong>Attachments</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{attachments.map((item, index) => {
|
||||
const att = item.attachment
|
||||
const linkId = att.id ?? item.report_id
|
||||
if (!linkId) return null
|
||||
const label = `${item.subject} • ${att.name ?? 'Attachment'}`
|
||||
return (
|
||||
<a
|
||||
key={`${linkId}-${index}`}
|
||||
className="btn btn-outline-primary w-100 text-start mb-2"
|
||||
href={attachmentHref({ ...att, id: linkId })}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Mirrors `ClassProgressController::splitUnitTitleForDisplay` / CI `class_progress_unit_display.php`. */
|
||||
|
||||
export function formatProgressDate(value?: string | null, withTime = false) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return withTime ? date.toLocaleString() : date.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function splitUnitTitle(unitTitle?: string | null) {
|
||||
const raw = String(unitTitle ?? '').trim()
|
||||
if (!raw) return { curriculum: [] as string[], custom: [] as string[] }
|
||||
const segments = raw
|
||||
.split(/\s*[;|]\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
const curriculum: string[] = []
|
||||
const custom: string[] = []
|
||||
for (const segment of segments) {
|
||||
if (/custom/i.test(segment)) custom.push(segment.replace(/^custom[:\s-]*/i, '').trim() || segment)
|
||||
else curriculum.push(segment)
|
||||
}
|
||||
if (curriculum.length === 0 && custom.length === 0) curriculum.push(raw)
|
||||
return { curriculum, custom }
|
||||
}
|
||||
|
||||
export const FLAG_LABELS: Record<string, string> = {
|
||||
homework_assigned: 'Homework assigned',
|
||||
quiz_done: 'Quiz / check-in completed',
|
||||
materials_missing: 'Materials needed',
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
fetchCommunicationsCompose,
|
||||
fetchFamilyGuardians,
|
||||
fetchStudentFamilies,
|
||||
previewCommunicationsEmail,
|
||||
sendCommunicationsEmail,
|
||||
} from '../../api/session'
|
||||
import type { CommunicationsStudentOption } from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthProvider'
|
||||
|
||||
export function CommunicationsIndexPage() {
|
||||
const { user } = useAuth()
|
||||
const [searchParams] = useSearchParams()
|
||||
const preStudent = searchParams.get('student_id')
|
||||
const preTemplate = searchParams.get('template_key') ?? ''
|
||||
|
||||
const [students, setStudents] = useState<CommunicationsStudentOption[]>([])
|
||||
const [templates, setTemplates] = useState<{ template_key: string; name?: string | null }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [studentId, setStudentId] = useState('')
|
||||
const [familyId, setFamilyId] = useState('')
|
||||
const [familyOptions, setFamilyOptions] = useState<{ id: number; label: string }[]>([])
|
||||
const [loadingFamilies, setLoadingFamilies] = useState(false)
|
||||
const [templateKey, setTemplateKey] = useState(preTemplate)
|
||||
|
||||
const [recipients, setRecipients] = useState<string[]>([])
|
||||
const [subject, setSubject] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [sendMsg, setSendMsg] = useState<string | null>(null)
|
||||
const prefillDone = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchCommunicationsCompose()
|
||||
.then((ctx) => {
|
||||
if (cancelled) return
|
||||
setStudents(ctx.students ?? [])
|
||||
setTemplates(ctx.templates ?? [])
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to load compose options.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
prefillDone.current = null
|
||||
}, [preStudent])
|
||||
|
||||
async function loadGuardians(fid: string) {
|
||||
setRecipients([])
|
||||
if (!fid) return
|
||||
try {
|
||||
const res = await fetchFamilyGuardians(Number(fid))
|
||||
const list = (res as { data?: unknown }).data
|
||||
const rows = Array.isArray(list) ? list : []
|
||||
const emails: string[] = []
|
||||
for (const g of rows as { email?: string | null; receive_emails?: boolean }[]) {
|
||||
if (g.receive_emails && g.email) emails.push(g.email.trim())
|
||||
}
|
||||
setRecipients([...new Set(emails)])
|
||||
} catch {
|
||||
setRecipients([])
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFamilies(sid: string) {
|
||||
setFamilyId('')
|
||||
setRecipients([])
|
||||
setFamilyOptions([])
|
||||
if (!sid) return
|
||||
setLoadingFamilies(true)
|
||||
try {
|
||||
const res = await fetchStudentFamilies(Number(sid))
|
||||
const list = (res as { data?: unknown }).data
|
||||
const rows = Array.isArray(list) ? list : []
|
||||
const mapped = rows.map((f: { id?: number; household_name?: string; is_primary_home?: boolean }) => ({
|
||||
id: Number(f.id),
|
||||
label:
|
||||
(f.household_name?.trim() || '') !== ''
|
||||
? String(f.household_name)
|
||||
: `Family #${f.id}` + (f.is_primary_home ? ' (primary)' : ''),
|
||||
}))
|
||||
setFamilyOptions(mapped)
|
||||
if (mapped.length > 0) {
|
||||
const first = String(mapped[0]!.id)
|
||||
setFamilyId(first)
|
||||
void loadGuardians(first)
|
||||
}
|
||||
} catch {
|
||||
setFamilyOptions([])
|
||||
} finally {
|
||||
setLoadingFamilies(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!preStudent || students.length === 0) return
|
||||
if (!students.some((s) => String(s.id) === preStudent)) return
|
||||
if (prefillDone.current === preStudent) return
|
||||
prefillDone.current = preStudent
|
||||
setStudentId(preStudent)
|
||||
void loadFamilies(preStudent)
|
||||
}, [preStudent, students])
|
||||
|
||||
async function refreshPreview() {
|
||||
if (!studentId || !familyId || !templateKey) return
|
||||
setBusy(true)
|
||||
setSendMsg(null)
|
||||
try {
|
||||
const vars: Record<string, string> = {
|
||||
school_name: 'Al Rahma Sunday School',
|
||||
teacher_name: user?.name ?? 'Teacher',
|
||||
class_section: '',
|
||||
student_grade: '',
|
||||
attendance_message: '',
|
||||
assessment_name: '',
|
||||
score_obtained: '',
|
||||
score_total: '',
|
||||
teacher_comments: '',
|
||||
behavior_summary: '',
|
||||
actions_taken: '',
|
||||
next_steps: '',
|
||||
}
|
||||
const data = await previewCommunicationsEmail({
|
||||
student_id: Number(studentId),
|
||||
family_id: Number(familyId),
|
||||
template_key: templateKey,
|
||||
vars,
|
||||
})
|
||||
if (data.subject) setSubject(data.subject)
|
||||
if (data.html)
|
||||
setBody(data.html.replace(/<br\s*\/?>/gi, '\n'))
|
||||
} catch (e: unknown) {
|
||||
setSendMsg(e instanceof ApiHttpError ? e.message : 'Preview failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSend(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!studentId || !familyId || !templateKey || !subject.trim()) return
|
||||
setBusy(true)
|
||||
setSendMsg(null)
|
||||
try {
|
||||
await sendCommunicationsEmail({
|
||||
student_id: Number(studentId),
|
||||
family_id: Number(familyId),
|
||||
template_key: templateKey,
|
||||
subject: subject.trim(),
|
||||
body,
|
||||
recipients,
|
||||
})
|
||||
setSendMsg('Message sent.')
|
||||
} catch (err: unknown) {
|
||||
setSendMsg(err instanceof ApiHttpError ? err.message : 'Send failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function removeRecipient(email: string) {
|
||||
setRecipients((r) => r.filter((x) => x !== email))
|
||||
}
|
||||
|
||||
const previewSafe = useMemo(() => ({ __html: body.replace(/\n/g, '<br/>') }), [body])
|
||||
|
||||
return (
|
||||
<div className="container my-4">
|
||||
<h2 className="mb-3">Family Communications</h2>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">Compose</div>
|
||||
<div className="card-body">
|
||||
<form id="comm-form" onSubmit={onSend}>
|
||||
<div className="row g-3">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="student_id">
|
||||
Student
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="student_id"
|
||||
name="student_id"
|
||||
required
|
||||
value={studentId}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value
|
||||
setStudentId(v)
|
||||
void loadFamilies(v)
|
||||
}}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{students.map((st) => (
|
||||
<option key={st.id} value={st.id}>
|
||||
{[st.lastname, st.firstname].filter(Boolean).join(', ') || `Student ${st.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="family_id">
|
||||
Family (if multiple)
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="family_id"
|
||||
name="family_id"
|
||||
required
|
||||
disabled={!studentId || loadingFamilies}
|
||||
value={familyId}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value
|
||||
setFamilyId(v)
|
||||
void loadGuardians(v)
|
||||
}}
|
||||
>
|
||||
<option value="">{studentId ? 'Select…' : 'Select a student first…'}</option>
|
||||
{familyOptions.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="template_key">
|
||||
Subject Category
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="template_key"
|
||||
name="template_key"
|
||||
required
|
||||
value={templateKey}
|
||||
onChange={(ev) => setTemplateKey(ev.target.value)}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.template_key} value={t.template_key}>
|
||||
{(t.name?.trim() || t.template_key) as string}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<label className="form-label">Recipients (Guardians)</label>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
{recipients.map((em) => (
|
||||
<span key={em} className="badge bg-light text-dark border">
|
||||
{em}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-link p-0 ms-1"
|
||||
onClick={() => removeRecipient(em)}
|
||||
aria-label={`Remove ${em}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div className="row g-3">
|
||||
<div className="col-md-8">
|
||||
<label className="form-label" htmlFor="subject">
|
||||
Email Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
value={subject}
|
||||
onChange={(ev) => setSubject(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4 d-flex align-items-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary ms-auto"
|
||||
disabled={busy}
|
||||
onClick={() => void refreshPreview()}
|
||||
>
|
||||
Refresh Preview
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="body">
|
||||
Email Body (editable)
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="body"
|
||||
name="body"
|
||||
rows={10}
|
||||
required
|
||||
value={body}
|
||||
onChange={(ev) => setBody(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sendMsg ? (
|
||||
<div
|
||||
className={`alert mt-3 py-2 ${sendMsg.includes('fail') ? 'alert-danger' : 'alert-success'}`}
|
||||
>
|
||||
{sendMsg}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#communicationsPreviewModal"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal fade" id="communicationsPreviewModal" tabIndex={-1}>
|
||||
<div className="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Email Preview</h5>
|
||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<h6 className="mb-3">{subject}</h6>
|
||||
<div dangerouslySetInnerHTML={previewSafe} />
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { ApiScopeNote, CompetitionPageShell } from './competitionWinnersShared'
|
||||
|
||||
/** CI `admin/competition_winners/form.php` — layout parity; persist requires admin competition APIs. */
|
||||
export function CompetitionWinnerFormPage() {
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
return (
|
||||
<CompetitionPageShell title={isEdit ? 'Edit Competition' : 'Create Competition'}>
|
||||
<ApiScopeNote>
|
||||
Saving competitions and per-class prize grids requires POST endpoints that mirror CI (
|
||||
<code>store</code>, <code>update</code>). Connect the API to enable the fields below.
|
||||
</ApiScopeNote>
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body row g-3">
|
||||
<div className="col-md-8">
|
||||
<label className="form-label">Title *</label>
|
||||
<input className="form-control" disabled placeholder="Competition title" />
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Class Section (optional)</label>
|
||||
<select className="form-select" disabled>
|
||||
<option value="">All classes</option>
|
||||
</select>
|
||||
<div className="form-text">Leave empty to manage winners for all classes.</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Semester</label>
|
||||
<input className="form-control" disabled />
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">School Year</label>
|
||||
<input className="form-control" disabled placeholder="2025-2026" />
|
||||
<div className="form-text">Counts use the school year above.</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Start Date</label>
|
||||
<input className="form-control" type="date" disabled />
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">End Date</label>
|
||||
<input className="form-control" type="date" disabled />
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<h5 className="mt-2">Winners per Class</h5>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-sm" data-no-mgmt-sticky>
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th style={{ width: 140 }}>Students</th>
|
||||
<th style={{ width: 140 }}>Auto Winners</th>
|
||||
<th style={{ width: 150 }}>Questions</th>
|
||||
<th style={{ width: 120 }}>1st</th>
|
||||
<th style={{ width: 120 }}>2nd</th>
|
||||
<th style={{ width: 120 }}>3rd</th>
|
||||
<th style={{ width: 120 }}>4th</th>
|
||||
<th style={{ width: 120 }}>5th</th>
|
||||
<th style={{ width: 120 }}>6th</th>
|
||||
<th style={{ width: 160 }}>Override</th>
|
||||
<th style={{ width: 140 }}>Final Winners</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={12} className="text-muted small">
|
||||
Class rows load from the backend when competition settings APIs are available.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<button type="button" className="btn btn-primary" disabled>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CompetitionPageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { fetchPublishedCompetition } from '../../api/session'
|
||||
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types'
|
||||
import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared'
|
||||
|
||||
function usePublishedCompetition(id: number) {
|
||||
const [competition, setCompetition] = useState<PublicCompetitionRow | null>(null)
|
||||
const [winnersByClass, setWinnersByClass] = useState<Record<string, PublicCompetitionWinnerRow[]>>({})
|
||||
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(id) || id <= 0) return
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetchPublishedCompetition(id)
|
||||
if (!response.status || !response.data) {
|
||||
setError(response.message ?? 'Competition not found.')
|
||||
return
|
||||
}
|
||||
setCompetition(response.data.competition)
|
||||
setWinnersByClass(response.data.winners_by_class ?? {})
|
||||
setSectionMap(response.data.section_map ?? {})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load winners.')
|
||||
}
|
||||
})()
|
||||
}, [id])
|
||||
|
||||
return { competition, winnersByClass, sectionMap, error }
|
||||
}
|
||||
|
||||
/** CI `admin/competition_winners/preview.php` */
|
||||
export function CompetitionWinnerPreviewPage() {
|
||||
const { id } = useParams()
|
||||
const competitionId = Number(id)
|
||||
const { competition, winnersByClass, sectionMap, error } = usePublishedCompetition(competitionId)
|
||||
|
||||
return (
|
||||
<CompetitionPageShell title="Preview Winners">
|
||||
<ApiScopeNote>
|
||||
Preview reflects published winners from the public API. Unpublished drafts are not shown until the backend exposes an
|
||||
admin preview.
|
||||
</ApiScopeNote>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{competition ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<strong>Competition:</strong> {competitionLabel(competition)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>School Year:</strong> {competition.school_year ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
{Object.keys(winnersByClass).length === 0 ? (
|
||||
<div className="alert alert-warning">No published winners found for this competition.</div>
|
||||
) : (
|
||||
Object.entries(winnersByClass).map(([classId, rows]) => (
|
||||
<div key={classId} className="mb-4">
|
||||
<h5 className="mt-4">Top Winners - {sectionMap[classId] ?? `Class ${classId}`}</h5>
|
||||
<table className="table table-bordered table-sm">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Student</th>
|
||||
<th>Score</th>
|
||||
<th>Prize</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={`${classId}-${index}`}>
|
||||
<td>{row.rank ?? index + 1}</td>
|
||||
<td>
|
||||
{row.name ??
|
||||
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
|
||||
`Student #${row.student_id ?? ''}`)}
|
||||
</td>
|
||||
<td>{row.score ?? '—'}</td>
|
||||
<td>{row.prize_amount ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</CompetitionPageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
fetchCompetitionScoresDetail,
|
||||
saveCompetitionScores,
|
||||
} from '../../api/session'
|
||||
import type { CompetitionScoresEditResponse, CompetitionScoreStudentRow } from '../../api/types'
|
||||
import {
|
||||
ApiScopeNote,
|
||||
CompetitionPageShell,
|
||||
COMPETITION_WINNERS_BASE,
|
||||
competitionLabel,
|
||||
} from './competitionWinnersShared'
|
||||
|
||||
/** CI `admin/competition_winners/scores.php` — enter scores (teacher competition API). */
|
||||
export function CompetitionWinnerScoresPage() {
|
||||
const { id } = useParams()
|
||||
const competitionId = Number(id)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const selectedClassId = Number(searchParams.get('class_section_id') ?? 0) || null
|
||||
const [data, setData] = useState<CompetitionScoresEditResponse | null>(null)
|
||||
const [scores, setScores] = useState<Record<string, number | string>>({})
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function load(classSectionId = selectedClassId) {
|
||||
try {
|
||||
const response = await fetchCompetitionScoresDetail(competitionId, classSectionId)
|
||||
setData(response)
|
||||
setScores(response.scoreMap ?? {})
|
||||
setError(response.ok ? null : response.message ?? 'Unable to load competition scores.')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load competition scores.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(competitionId) || competitionId <= 0) return
|
||||
void load()
|
||||
}, [competitionId, selectedClassId])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!competitionId) return
|
||||
try {
|
||||
const result = await saveCompetitionScores(competitionId, {
|
||||
class_section_id: data?.classSectionId ?? selectedClassId,
|
||||
scores,
|
||||
})
|
||||
setMessage(result.message ?? 'Scores saved.')
|
||||
if (result.ok) {
|
||||
await load()
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to save scores.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CompetitionPageShell title="Enter Scores">
|
||||
<ApiScopeNote>
|
||||
Uses the teacher-facing competition-scores API; your account must be assigned to the class section being scored.
|
||||
</ApiScopeNote>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{message ? <div className="alert alert-success">{message}</div> : null}
|
||||
{data?.competition ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<strong>Competition:</strong> {competitionLabel(data.competition)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Class Section:</strong> {data.classSectionName ?? '—'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong>{' '}
|
||||
{data.questionCount ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={(e) => void onSubmit(e)}>
|
||||
{data.classSelectionLocked === false ? (
|
||||
<div className="mb-3" style={{ maxWidth: 360 }}>
|
||||
<label className="form-label">Class Section ID</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={String(selectedClassId ?? data.classSectionId ?? '')}
|
||||
onChange={(e) => setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-sm align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>School ID</th>
|
||||
<th>Student</th>
|
||||
<th>Score{data.questionCount ? ` (max ${data.questionCount})` : ''}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data.students ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-muted">
|
||||
No students loaded for this competition.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
(data.students ?? []).map((student: CompetitionScoreStudentRow) => {
|
||||
const studentId = Number(student.id ?? student.student_id ?? 0)
|
||||
const name =
|
||||
`${student.firstname ?? ''} ${student.lastname ?? ''}`.trim() ||
|
||||
`Student #${studentId}`
|
||||
return (
|
||||
<tr key={studentId}>
|
||||
<td>{student.school_id ?? studentId}</td>
|
||||
<td>{name}</td>
|
||||
<td>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max={data.questionCount ?? undefined}
|
||||
disabled={Boolean(data.isLocked)}
|
||||
value={String(scores[String(studentId)] ?? '')}
|
||||
onChange={(e) =>
|
||||
setScores((current) => ({
|
||||
...current,
|
||||
[String(studentId)]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
<button className="btn btn-primary" disabled={Boolean(data.isLocked)}>
|
||||
Save Scores
|
||||
</button>
|
||||
<Link className="btn btn-outline-secondary" to={`${COMPETITION_WINNERS_BASE}/${competitionId}/preview`}>
|
||||
Preview
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted mb-0">No competition data loaded.</p>
|
||||
)}
|
||||
</CompetitionPageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { fetchPublishedCompetition } from '../../api/session'
|
||||
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types'
|
||||
import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared'
|
||||
|
||||
function usePublishedCompetitionFull(id: number) {
|
||||
const [competition, setCompetition] = useState<PublicCompetitionRow | null>(null)
|
||||
const [winnersByClass, setWinnersByClass] = useState<Record<string, PublicCompetitionWinnerRow[]>>({})
|
||||
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
|
||||
const [questionCounts, setQuestionCounts] = useState<Record<string, number>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(id) || id <= 0) return
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetchPublishedCompetition(id)
|
||||
if (!response.status || !response.data) {
|
||||
setError(response.message ?? 'Competition not found.')
|
||||
return
|
||||
}
|
||||
setCompetition(response.data.competition)
|
||||
setWinnersByClass(response.data.winners_by_class ?? {})
|
||||
setSectionMap(response.data.section_map ?? {})
|
||||
setQuestionCounts(response.data.question_counts ?? {})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load winners.')
|
||||
}
|
||||
})()
|
||||
}, [id])
|
||||
|
||||
return { competition, winnersByClass, sectionMap, questionCounts, error }
|
||||
}
|
||||
|
||||
/** CI `admin/competition_winners/winners.php` */
|
||||
export function CompetitionWinnerWinnersPage() {
|
||||
const { id } = useParams()
|
||||
const competitionId = Number(id)
|
||||
const { competition, winnersByClass, sectionMap, questionCounts, error } =
|
||||
usePublishedCompetitionFull(competitionId)
|
||||
|
||||
const flatRows = useMemo(
|
||||
() =>
|
||||
Object.entries(winnersByClass).flatMap(([classId, rows]) =>
|
||||
rows.map((row) => ({ ...row, _classId: classId })),
|
||||
),
|
||||
[winnersByClass],
|
||||
)
|
||||
|
||||
const totalPrize = flatRows.reduce((sum, row) => sum + Number(row.prize_amount ?? 0), 0)
|
||||
|
||||
return (
|
||||
<CompetitionPageShell title="School Winners">
|
||||
<ApiScopeNote>
|
||||
Prize totals and rankings come from the published winners API. Export to recognition PDF requires an admin endpoint if
|
||||
not yet in Laravel.
|
||||
</ApiScopeNote>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{competition ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<strong>Competition:</strong> {competitionLabel(competition)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>School Year:</strong> {competition.school_year ?? '—'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Published:</strong> {competition.is_published ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
{flatRows.length === 0 ? (
|
||||
<div className="alert alert-warning">No published winners yet.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-sm">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th>Student</th>
|
||||
<th>Rank</th>
|
||||
<th>Score</th>
|
||||
<th>Question Count</th>
|
||||
<th>Prize</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{flatRows.map((row, index) => (
|
||||
<tr key={`${row._classId}-${index}`}>
|
||||
<td>{sectionMap[String(row._classId)] ?? `Class ${row._classId}`}</td>
|
||||
<td>
|
||||
{row.name ??
|
||||
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
|
||||
`Student #${row.student_id ?? ''}`)}
|
||||
</td>
|
||||
<td>{row.rank ?? '—'}</td>
|
||||
<td>{row.score ?? '—'}</td>
|
||||
<td>{questionCounts[String(row._classId)] ?? '—'}</td>
|
||||
<td>{row.prize_amount ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colSpan={5} className="text-end">
|
||||
Total Prizes
|
||||
</th>
|
||||
<th>{totalPrize.toFixed(2)}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</CompetitionPageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchCompetitionScoresIndex, fetchPublishedCompetitions } from '../../api/session'
|
||||
import type { CompetitionScoresIndexCompetitionRow, PublicCompetitionRow } from '../../api/types'
|
||||
import { ApiScopeNote, CompetitionPageShell, COMPETITION_WINNERS_BASE } from './competitionWinnersShared'
|
||||
|
||||
/** CI `admin/competition_winners/index.php` */
|
||||
export function CompetitionWinnersIndexPage() {
|
||||
const [publishedRows, setPublishedRows] = useState<PublicCompetitionRow[]>([])
|
||||
const [adminRows, setAdminRows] = useState<CompetitionScoresIndexCompetitionRow[]>([])
|
||||
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
|
||||
const [activeClassName, setActiveClassName] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const [published, teacher] = await Promise.allSettled([
|
||||
fetchPublishedCompetitions(),
|
||||
fetchCompetitionScoresIndex(),
|
||||
])
|
||||
|
||||
if (published.status === 'fulfilled') {
|
||||
setPublishedRows(published.value.data?.competitions ?? [])
|
||||
}
|
||||
|
||||
if (teacher.status === 'fulfilled') {
|
||||
setAdminRows(teacher.value.competitions ?? [])
|
||||
setSectionMap(teacher.value.sectionMap ?? {})
|
||||
setActiveClassName(teacher.value.activeClassName ?? null)
|
||||
}
|
||||
|
||||
if (published.status === 'rejected' && teacher.status === 'rejected') {
|
||||
setError('Unable to load competition winners data.')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load competition winners data.')
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const tableRows = useMemo(() => {
|
||||
if (adminRows.length > 0) return adminRows
|
||||
return publishedRows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
class_section_id: r.class_section_id,
|
||||
is_locked: r.is_locked,
|
||||
is_published: r.is_published,
|
||||
})) as CompetitionScoresIndexCompetitionRow[]
|
||||
}, [adminRows, publishedRows])
|
||||
|
||||
return (
|
||||
<CompetitionPageShell title="Competition Winners">
|
||||
<ApiScopeNote>
|
||||
Lock/unlock, export quiz, and competition CRUD match CI once Laravel exposes those admin routes. Score entry and
|
||||
published winners work with the current API.
|
||||
</ApiScopeNote>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div />
|
||||
<Link className="btn btn-primary" to={`${COMPETITION_WINNERS_BASE}/create`}>
|
||||
+ New Competition
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-sm align-middle mb-0" data-no-mgmt-sticky>
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Class Section</th>
|
||||
<th>Winners Rule</th>
|
||||
<th>Published</th>
|
||||
<th>Locked</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-muted">
|
||||
No competitions found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tableRows.map((c) => {
|
||||
const sectionId = Number(c.class_section_id ?? 0)
|
||||
const sectionName =
|
||||
sectionId > 0 ? (sectionMap[String(sectionId)] ?? String(sectionId)) : 'All'
|
||||
const isLocked = Boolean(c.is_locked)
|
||||
const isPublished = Boolean(c.is_published)
|
||||
const id = Number(c.id)
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td>{id}</td>
|
||||
<td>{String(c.title ?? `Competition #${id}`)}</td>
|
||||
<td>{sectionName}</td>
|
||||
<td>Tiered per class</td>
|
||||
<td>{isPublished ? 'Yes' : 'No'}</td>
|
||||
<td>{isLocked ? 'Yes' : 'No'}</td>
|
||||
<td>
|
||||
<div className="d-flex flex-wrap gap-1">
|
||||
<Link
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}>
|
||||
Enter Scores
|
||||
</Link>
|
||||
<Link
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
to={`${COMPETITION_WINNERS_BASE}/${id}/preview`}
|
||||
>
|
||||
Preview
|
||||
</Link>
|
||||
<Link
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
to={`${COMPETITION_WINNERS_BASE}/${id}/winners`}
|
||||
>
|
||||
Winners
|
||||
</Link>
|
||||
<button type="button" className="btn btn-sm btn-outline-success" disabled title="Requires API">
|
||||
Export Scores to Quiz
|
||||
</button>
|
||||
{isLocked ? (
|
||||
<button type="button" className="btn btn-sm btn-outline-warning" disabled title="Requires API">
|
||||
Unlock
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-sm btn-outline-danger" disabled title="Requires API">
|
||||
Lock
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{activeClassName ? (
|
||||
<p className="text-muted small mt-3 mb-0">Active class context: {activeClassName}</p>
|
||||
) : null}
|
||||
</CompetitionPageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Canonical SPA base for competition winners admin (JWT `GET /api/v1/competition-scores`, etc.). */
|
||||
export const COMPETITION_WINNERS_BASE = '/app/competition-winners'
|
||||
|
||||
export function CompetitionPageShell({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<div className="mb-4">
|
||||
<h2 className="h4 mb-1">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatCompetitionDate(value?: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function competitionLabel(row?: { title?: string | null; id?: number | null } | null) {
|
||||
return row?.title ?? `Competition #${row?.id ?? ''}`
|
||||
}
|
||||
|
||||
/** Soft notice when admin CRUD APIs are not wired (CI `competition_winners/index.php` expects full admin actions). */
|
||||
export function ApiScopeNote({ children }: { children: ReactNode }) {
|
||||
return <div className="alert alert-info border-0 bg-light">{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
createConfigurationEntry,
|
||||
deleteConfigurationEntry,
|
||||
fetchConfigurations,
|
||||
updateConfigurationEntry,
|
||||
} from '../../api/session'
|
||||
import type { ConfigurationRow } from '../../api/types'
|
||||
|
||||
export function ConfigurationViewPage() {
|
||||
const [rows, setRows] = useState<ConfigurationRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [flash, setFlash] = useState<string | null>(null)
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newVal, setNewVal] = useState('')
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editRow, setEditRow] = useState<ConfigurationRow | null>(null)
|
||||
const [editKey, setEditKey] = useState('')
|
||||
const [editVal, setEditVal] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function reload() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchConfigurations()
|
||||
setRows(data.configs ?? [])
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to load configuration.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [])
|
||||
|
||||
async function onAdd(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!newKey.trim() || !newVal.trim()) return
|
||||
setBusy(true)
|
||||
setFlash(null)
|
||||
try {
|
||||
await createConfigurationEntry({ config_key: newKey.trim(), config_value: newVal.trim() })
|
||||
setFlash('Saved.')
|
||||
setNewKey('')
|
||||
setNewVal('')
|
||||
setShowAdd(false)
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
setFlash(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: ConfigurationRow) {
|
||||
setEditRow(row)
|
||||
setEditKey(row.config_key)
|
||||
setEditVal(row.config_value)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
async function onEditSave(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!editRow) return
|
||||
setBusy(true)
|
||||
setFlash(null)
|
||||
try {
|
||||
await updateConfigurationEntry(editRow.id, {
|
||||
config_key: editKey.trim(),
|
||||
config_value: editVal.trim(),
|
||||
})
|
||||
setFlash('Updated.')
|
||||
setEditOpen(false)
|
||||
setEditRow(null)
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
setFlash(err instanceof ApiHttpError ? err.message : 'Update failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(id: number | string) {
|
||||
if (!window.confirm('Are you sure you want to delete this config?')) return
|
||||
setBusy(true)
|
||||
setFlash(null)
|
||||
try {
|
||||
await deleteConfigurationEntry(id)
|
||||
setFlash('Deleted.')
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
setFlash(err instanceof ApiHttpError ? err.message : 'Delete failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="mt-4 mb-3">Configuration Management</h2>
|
||||
</div>
|
||||
|
||||
{flash ? (
|
||||
<div className={`alert ${flash.includes('fail') ? 'alert-danger' : 'alert-success'}`}>{flash}</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted text-center">Loading configurations…</p>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : null}
|
||||
|
||||
{showAdd ? (
|
||||
<form onSubmit={onAdd} className="mb-4 border rounded p-3 bg-light">
|
||||
<div className="row">
|
||||
<div className="col-md-4 mb-3">
|
||||
<label className="form-label" htmlFor="new_config_key">
|
||||
Config Key
|
||||
</label>
|
||||
<input
|
||||
id="new_config_key"
|
||||
className="form-control"
|
||||
value={newKey}
|
||||
onChange={(ev) => setNewKey(ev.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4 mb-3">
|
||||
<label className="form-label" htmlFor="new_config_value">
|
||||
Config Value
|
||||
</label>
|
||||
<input
|
||||
id="new_config_value"
|
||||
className="form-control"
|
||||
value={newVal}
|
||||
onChange={(ev) => setNewVal(ev.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowAdd(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Config Key</th>
|
||||
<th>Config Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center text-muted">
|
||||
No configurations found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr key={String(row.id)}>
|
||||
<td>{String(row.id)}</td>
|
||||
<td>{row.config_key}</td>
|
||||
<td>{row.config_value}</td>
|
||||
<td className="d-flex gap-1 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-warning"
|
||||
onClick={() => openEdit(row)}
|
||||
disabled={busy}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => void onDelete(row.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<button type="button" className="btn btn-success" onClick={() => setShowAdd((v) => !v)}>
|
||||
{showAdd ? 'Hide form' : 'Add New Config'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editOpen ? (
|
||||
<div className="modal fade show d-block" tabIndex={-1} style={{ background: 'rgba(0,0,0,0.4)' }}>
|
||||
<div className="modal-dialog">
|
||||
<form className="modal-content" onSubmit={onEditSave}>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Edit Configuration</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
aria-label="Close"
|
||||
onClick={() => setEditOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="modal_config_key">
|
||||
Config Key
|
||||
</label>
|
||||
<input
|
||||
id="modal_config_key"
|
||||
className="form-control"
|
||||
value={editKey}
|
||||
onChange={(ev) => setEditKey(ev.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="modal_config_value">
|
||||
Config Value
|
||||
</label>
|
||||
<input
|
||||
id="modal_config_value"
|
||||
className="form-control"
|
||||
value={editVal}
|
||||
onChange={(ev) => setEditVal(ev.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type FormEvent } from 'react'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
function defaultSchoolYear() {
|
||||
const y = new Date().getFullYear()
|
||||
return `${y}-${y + 1}`
|
||||
}
|
||||
|
||||
export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const years =
|
||||
schoolYears && schoolYears.length > 0
|
||||
? schoolYears
|
||||
: (() => {
|
||||
const out: string[] = []
|
||||
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
|
||||
return out
|
||||
})()
|
||||
|
||||
function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const next = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
if (sy) next.set('school_year', sy)
|
||||
if (sem) next.set('semester', sem)
|
||||
navigate({ pathname, search: next.toString() ? `?${next}` : '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="row g-2 align-items-center justify-content-center mb-3"
|
||||
>
|
||||
<div className="col-auto">
|
||||
<label className="form-label mb-0">School year</label>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select
|
||||
name="school_year"
|
||||
className="form-select form-select-sm"
|
||||
style={{ minWidth: 180 }}
|
||||
defaultValue={schoolYear || years[0] || defaultSchoolYear()}
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<label className="form-label mb-0">Semester</label>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select
|
||||
name="semester"
|
||||
className="form-select form-select-sm"
|
||||
style={{ minWidth: 140 }}
|
||||
defaultValue={semester}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="Fall">Fall</option>
|
||||
<option value="Spring">Spring</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button type="submit" className="btn btn-secondary btn-sm">
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm ms-1"
|
||||
onClick={() => navigate({ pathname, search: '' })}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { applyDiscountVoucher, fetchDiscountApplyContext } from '../../api/session'
|
||||
import type { DiscountApplyParentRow, DiscountVoucherRow } from '../../api/types'
|
||||
import { AcademicFilterBar } from './AcademicFilterBar'
|
||||
|
||||
export function DiscountApplyVoucherPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? undefined
|
||||
const semester = searchParams.get('semester') ?? undefined
|
||||
|
||||
const [vouchers, setVouchers] = useState<DiscountVoucherRow[]>([])
|
||||
const [voucherId, setVoucherId] = useState('')
|
||||
const [allowAdditional, setAllowAdditional] = useState(true)
|
||||
const [parents, setParents] = useState<DiscountApplyParentRow[]>([])
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
||||
const [checkAll, setCheckAll] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchDiscountApplyContext({ school_year: schoolYear, semester })
|
||||
.then((d) => {
|
||||
if (cancelled) return
|
||||
setParents(d.parents ?? [])
|
||||
setVouchers(d.vouchers ?? [])
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load parents or vouchers.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
const selectableParents = useMemo(
|
||||
() =>
|
||||
parents.filter((p) => {
|
||||
if (!p.has_discount) return true
|
||||
return allowAdditional
|
||||
}),
|
||||
[parents, allowAdditional],
|
||||
)
|
||||
|
||||
function toggleParent(id: string, enabled: boolean) {
|
||||
if (!enabled) return
|
||||
setSelected((s) => ({ ...s, [id]: !s[id] }))
|
||||
}
|
||||
|
||||
function onCheckAll(checked: boolean) {
|
||||
setCheckAll(checked)
|
||||
const next: Record<string, boolean> = {}
|
||||
if (checked) {
|
||||
for (const p of selectableParents) {
|
||||
next[String(p.id)] = true
|
||||
}
|
||||
}
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelected({})
|
||||
setCheckAll(false)
|
||||
}, [allowAdditional, parents])
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!voucherId) return
|
||||
const ids = Object.entries(selected)
|
||||
.filter(([, on]) => on)
|
||||
.map(([id]) => id)
|
||||
if (ids.length === 0) {
|
||||
setMsg('Select at least one parent.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setMsg(null)
|
||||
try {
|
||||
await applyDiscountVoucher({
|
||||
voucher_id: voucherId,
|
||||
parent_ids: ids,
|
||||
allow_additional: allowAdditional,
|
||||
school_year: schoolYear,
|
||||
semester,
|
||||
})
|
||||
setMsg('Voucher applied.')
|
||||
} catch (err: unknown) {
|
||||
setMsg(err instanceof ApiHttpError ? err.message : 'Apply failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h1>Apply Discount Voucher</h1>
|
||||
<AcademicFilterBar />
|
||||
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{msg ? (
|
||||
<div className={`alert ${msg.includes('fail') || msg.includes('Select') ? 'alert-warning' : 'alert-success'}`}>
|
||||
{msg}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="d-flex gap-2 mb-3 justify-content-end">
|
||||
<button type="submit" className="btn btn-success" disabled={busy || loading}>
|
||||
Apply Voucher
|
||||
</button>
|
||||
<Link to="/app/administrator/discounts" className="btn btn-info">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="voucher_id">
|
||||
Select Voucher Code
|
||||
</label>
|
||||
<select
|
||||
name="voucher_id"
|
||||
id="voucher_id"
|
||||
className="form-select"
|
||||
required
|
||||
value={voucherId}
|
||||
onChange={(ev) => setVoucherId(ev.target.value)}
|
||||
>
|
||||
<option value="">-- Select Voucher --</option>
|
||||
{vouchers.map((v) => (
|
||||
<option key={String(v.id)} value={String(v.id)}>
|
||||
{v.code} ({String(v.discount_type)} —{' '}
|
||||
{v.discount_type === 'percent' ? `${v.discount_value}%` : `$${v.discount_value}`})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="allowAdditional"
|
||||
checked={allowAdditional}
|
||||
onChange={(ev) => setAllowAdditional(ev.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="allowAdditional">
|
||||
Allow additional discounts for parents who already have discounts
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Select Parents</label>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkAll}
|
||||
onChange={(ev) => onCheckAll(ev.target.checked)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th>Parent ID</th>
|
||||
<th>Parent Name</th>
|
||||
<th>Email</th>
|
||||
<th>Has Discount?</th>
|
||||
<th>Total Discount Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parents.map((parent) => {
|
||||
const id = String(parent.id)
|
||||
const hasDisc = Boolean(parent.has_discount)
|
||||
const enabled = !hasDisc || allowAdditional
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={`parent_${id}`}
|
||||
className="form-check-input"
|
||||
checked={Boolean(selected[id])}
|
||||
disabled={!enabled}
|
||||
onChange={() => toggleParent(id, enabled)}
|
||||
/>
|
||||
</td>
|
||||
<td>{parent.school_id ?? 'N/A'}</td>
|
||||
<td>
|
||||
{[parent.firstname, parent.lastname].filter(Boolean).join(' ') || '—'}
|
||||
</td>
|
||||
<td>{parent.email ?? '—'}</td>
|
||||
<td>{hasDisc ? 'Yes' : 'No'}</td>
|
||||
<td>${Number(parent.total_discount ?? 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { createDiscountVoucher } from '../../api/session'
|
||||
|
||||
export function DiscountCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const code = String(fd.get('code') ?? '').trim()
|
||||
const description = String(fd.get('description') ?? '')
|
||||
const discount_type = String(fd.get('discount_type') ?? 'percent')
|
||||
const discount_value = Number(fd.get('discount_value'))
|
||||
const max_uses_raw = String(fd.get('max_uses') ?? '').trim()
|
||||
const valid_from = String(fd.get('valid_from') ?? '').trim()
|
||||
const valid_until = String(fd.get('valid_until') ?? '').trim()
|
||||
const is_active = fd.get('is_active') === 'on'
|
||||
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createDiscountVoucher({
|
||||
code,
|
||||
description,
|
||||
discount_type,
|
||||
discount_value,
|
||||
max_uses: max_uses_raw === '' ? null : Number(max_uses_raw),
|
||||
valid_from: valid_from || null,
|
||||
valid_until: valid_until || null,
|
||||
is_active,
|
||||
})
|
||||
navigate('/app/administrator/discounts')
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Could not save voucher.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function autoGenerateCode() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 10; i++) code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
const input = document.getElementById('discount_code_input') as HTMLInputElement | null
|
||||
if (input) input.value = code
|
||||
const desc = document.getElementById('discount_description') as HTMLTextAreaElement | null
|
||||
if (desc) {
|
||||
desc.value = desc.value.replace(/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i, '')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h1>Create Discount Voucher</h1>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<form method="post" onSubmit={onSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="discount_code_input">
|
||||
Voucher Code
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
id="discount_code_input"
|
||||
className="form-control"
|
||||
placeholder="Enter or generate a code (e.g. WELCOME10)"
|
||||
required
|
||||
/>
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={autoGenerateCode}>
|
||||
Auto Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="discount_description">
|
||||
Description / Reason
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
id="discount_description"
|
||||
className="form-control"
|
||||
rows={3}
|
||||
placeholder="Reason for this voucher."
|
||||
/>
|
||||
<div className="form-text">Visible to admins only; not shown to parents.</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="discount_type">
|
||||
Discount Type
|
||||
</label>
|
||||
<select name="discount_type" id="discount_type" className="form-select" required>
|
||||
<option value="percent">Percentage (%)</option>
|
||||
<option value="fixed">Fixed Amount ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="discount_value">
|
||||
Discount Value
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="discount_value"
|
||||
id="discount_value"
|
||||
className="form-control"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="e.g. 10 for 10% or 50 for $50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="max_uses">
|
||||
Maximum Uses
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="max_uses"
|
||||
id="max_uses"
|
||||
className="form-control"
|
||||
placeholder="Leave empty for unlimited"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="valid_from">
|
||||
Valid From
|
||||
</label>
|
||||
<input type="date" name="valid_from" id="valid_from" className="form-control" />
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="valid_until">
|
||||
Valid Until
|
||||
</label>
|
||||
<input type="date" name="valid_until" id="valid_until" className="form-control" />
|
||||
</div>
|
||||
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
id="is_active"
|
||||
className="form-check-input"
|
||||
defaultChecked
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="is_active">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-success" disabled={busy}>
|
||||
Save Voucher
|
||||
</button>
|
||||
<Link to="/app/administrator/discounts" className="btn btn-info ms-2">
|
||||
Cancel
|
||||
</Link>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchDiscountsList } from '../../api/session'
|
||||
import type { DiscountVoucherRow } from '../../api/types'
|
||||
import { AcademicFilterBar } from './AcademicFilterBar'
|
||||
|
||||
function formatValidityDate(value?: string | null) {
|
||||
if (value == null || String(value).trim() === '') return 'N/A'
|
||||
const s = String(value)
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
|
||||
if (m) return `${m[2]}-${m[3]}-${m[1]}`
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? s : d.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function DiscountListPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? undefined
|
||||
const semester = searchParams.get('semester') ?? undefined
|
||||
|
||||
const [vouchers, setVouchers] = useState<DiscountVoucherRow[]>([])
|
||||
const [schoolYears, setSchoolYears] = useState<string[] | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchDiscountsList({ school_year: schoolYear, semester })
|
||||
.then((d) => {
|
||||
if (cancelled) return
|
||||
setVouchers(d.vouchers ?? [])
|
||||
setSchoolYears(d.school_years)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load discounts.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
return (
|
||||
<div className="container-fluid mt-4">
|
||||
<h2 className="text-center mt-4 mb-3">Discounts</h2>
|
||||
<AcademicFilterBar schoolYears={schoolYears} />
|
||||
|
||||
<div className="d-flex gap-2 mb-3 justify-content-end">
|
||||
<Link to="/app/administrator/discounts/create" className="btn btn-primary">
|
||||
Create New Voucher
|
||||
</Link>
|
||||
<Link to="/app/administrator/discounts/apply" className="btn btn-success">
|
||||
Apply Voucher to Parents
|
||||
</Link>
|
||||
<Link to="/app/administrator/discounts/reverse" className="btn btn-outline-warning">
|
||||
Reverse Discount
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : error ? (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
<th>Max Uses</th>
|
||||
<th>Times Used</th>
|
||||
<th>Validity</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vouchers.map((v) => (
|
||||
<tr key={String(v.id)}>
|
||||
<td>{v.code}</td>
|
||||
<td>{String(v.discount_type).charAt(0).toUpperCase() + String(v.discount_type).slice(1)}</td>
|
||||
<td>
|
||||
{v.discount_type === 'percent'
|
||||
? `${v.discount_value}%`
|
||||
: `$${Number(v.discount_value).toFixed(2)}`}
|
||||
</td>
|
||||
<td>{v.max_uses != null && v.max_uses !== '' ? String(v.max_uses) : '∞'}</td>
|
||||
<td>{v.times_used != null ? String(v.times_used) : '—'}</td>
|
||||
<td>
|
||||
{formatValidityDate(v.valid_from)} → {formatValidityDate(v.valid_until)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${v.is_active ? 'bg-success' : 'bg-secondary'}`}>
|
||||
{v.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-muted small">Edit (API)</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchDiscountApplyContext, reverseDiscountApplication } from '../../api/session'
|
||||
import type { DiscountApplyParentRow, DiscountVoucherRow } from '../../api/types'
|
||||
import { AcademicFilterBar } from './AcademicFilterBar'
|
||||
|
||||
/**
|
||||
* Parity for a legacy `reverse_discount` admin view (not present in the CI tree we inspected).
|
||||
* Uses the same parent/voucher context as “apply voucher”; backend should filter rows as needed.
|
||||
*/
|
||||
export function ReverseDiscountPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? undefined
|
||||
const semester = searchParams.get('semester') ?? undefined
|
||||
|
||||
const [vouchers, setVouchers] = useState<DiscountVoucherRow[]>([])
|
||||
const [parents, setParents] = useState<DiscountApplyParentRow[]>([])
|
||||
const [voucherId, setVoucherId] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchDiscountApplyContext({ school_year: schoolYear, semester })
|
||||
.then((d) => {
|
||||
if (cancelled) return
|
||||
setVouchers(d.vouchers ?? [])
|
||||
setParents(d.parents ?? [])
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load data.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((s) => ({ ...s, [id]: !s[id] }))
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const ids = Object.entries(selected)
|
||||
.filter(([, on]) => on)
|
||||
.map(([id]) => id)
|
||||
if (ids.length === 0) {
|
||||
setMsg('Select at least one parent.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setMsg(null)
|
||||
try {
|
||||
await reverseDiscountApplication({
|
||||
voucher_id: voucherId || undefined,
|
||||
parent_ids: ids,
|
||||
reason: reason.trim() || undefined,
|
||||
school_year: schoolYear,
|
||||
semester,
|
||||
})
|
||||
setMsg('Reverse request submitted.')
|
||||
} catch (err: unknown) {
|
||||
setMsg(err instanceof ApiHttpError ? err.message : 'Request failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h1 className="h2">Reverse Discount</h1>
|
||||
<p className="text-muted">
|
||||
Remove or roll back a voucher application for selected parents. The API route is{' '}
|
||||
<code>/api/v1/discounts/reverse</code>.
|
||||
</p>
|
||||
|
||||
<AcademicFilterBar />
|
||||
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{msg ? (
|
||||
<div
|
||||
className={`alert ${msg.includes('fail') || msg.includes('Select') ? 'alert-warning' : 'alert-success'}`}
|
||||
>
|
||||
{msg}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="reverse_voucher">
|
||||
Voucher (optional filter)
|
||||
</label>
|
||||
<select
|
||||
id="reverse_voucher"
|
||||
className="form-select"
|
||||
value={voucherId}
|
||||
onChange={(ev) => setVoucherId(ev.target.value)}
|
||||
>
|
||||
<option value="">— Any / not specified —</option>
|
||||
{vouchers.map((v) => (
|
||||
<option key={String(v.id)} value={String(v.id)}>
|
||||
{v.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="reverse_reason">
|
||||
Reason (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="reverse_reason"
|
||||
className="form-control"
|
||||
rows={2}
|
||||
value={reason}
|
||||
onChange={(ev) => setReason(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-2 mb-3 justify-content-end">
|
||||
<button type="submit" className="btn btn-warning" disabled={busy || loading}>
|
||||
Reverse discount
|
||||
</button>
|
||||
<Link to="/app/administrator/discounts" className="btn btn-outline-secondary">
|
||||
Back to list
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Select</th>
|
||||
<th>Parent ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Has discount?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parents.map((parent) => {
|
||||
const id = String(parent.id)
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={Boolean(selected[id])}
|
||||
onChange={() => toggle(id)}
|
||||
/>
|
||||
</td>
|
||||
<td>{parent.school_id ?? 'N/A'}</td>
|
||||
<td>{[parent.firstname, parent.lastname].filter(Boolean).join(' ') || '—'}</td>
|
||||
<td>{parent.email ?? '—'}</td>
|
||||
<td>{parent.has_discount ? 'Yes' : 'No'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Mirrors CodeIgniter `layout/email_layout.php` — scoped for admin preview only */
|
||||
.email-preview-root {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f9f9f9;
|
||||
padding: 40px 0;
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.email-preview-root .email-preview-container {
|
||||
background-color: #ffffff;
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.email-preview-root .email-preview-nopicture {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.email-preview-root .email-preview-logo {
|
||||
max-height: 200px;
|
||||
width: 150px;
|
||||
height: 110px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.email-preview-root .email-preview-body {
|
||||
padding: 10px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.email-preview-root .email-preview-footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #777777;
|
||||
padding: 20px;
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
.email-preview-root a {
|
||||
color: #007bff;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import './EmailPreviewLayout.css'
|
||||
|
||||
const DEFAULT_SITE = 'https://alrahmaisgl.org'
|
||||
const LOGO_SRC = `${DEFAULT_SITE}/assets/images/alrahma_logo.png`
|
||||
|
||||
export function EmailPreviewLayout({
|
||||
children,
|
||||
}: {
|
||||
/** Reserved for future Helmet / window title parity with CI `$subject` */
|
||||
subject?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="email-preview-root">
|
||||
<div className="email-preview-container">
|
||||
<div className="email-preview-nopicture">
|
||||
<img
|
||||
src={LOGO_SRC}
|
||||
alt=""
|
||||
className="email-preview-logo"
|
||||
width={150}
|
||||
height={110}
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="email-preview-body">{children}</div>
|
||||
<div className="email-preview-footer">
|
||||
<p>
|
||||
Visit us:{' '}
|
||||
<a href={DEFAULT_SITE} target="_blank" rel="noreferrer noopener">
|
||||
{DEFAULT_SITE}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Contact:{' '}
|
||||
<a href="mailto:alrahma.isgl@gmail.com">alrahma.isgl@gmail.com</a>
|
||||
</p>
|
||||
<p>Address: 5 Courthouse Lane, Chelmsford, MA 01824</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { EmailPreviewLayout } from './EmailPreviewLayout'
|
||||
import { EMAIL_TEMPLATE_REGISTRY, isEmailTemplateSlug } from './emailTemplatesRegistry'
|
||||
|
||||
export function EmailTemplatePreviewPage() {
|
||||
const { templateId } = useParams<{ templateId: string }>()
|
||||
const slug = templateId ?? ''
|
||||
|
||||
if (!isEmailTemplateSlug(slug)) {
|
||||
return (
|
||||
<div className="container py-4">
|
||||
<div className="alert alert-warning">Unknown template: {slug}</div>
|
||||
<Link to="/app/administrator/email-templates">Back to list</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const entry = EMAIL_TEMPLATE_REGISTRY[slug]
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<div className="d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||
<Link to="/app/administrator/email-templates" className="btn btn-sm btn-outline-secondary">
|
||||
← All templates
|
||||
</Link>
|
||||
<h2 className="h5 mb-0">{entry.title}</h2>
|
||||
</div>
|
||||
<p className="small text-muted mb-3">
|
||||
CI: <code>{entry.ciView}</code>
|
||||
</p>
|
||||
<EmailPreviewLayout>{entry.render()}</EmailPreviewLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { EMAIL_TEMPLATE_REGISTRY, type EmailTemplateSlug } from './emailTemplatesRegistry'
|
||||
|
||||
const SLUGS = Object.keys(EMAIL_TEMPLATE_REGISTRY) as EmailTemplateSlug[]
|
||||
|
||||
export function EmailTemplatesIndexPage() {
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="h4 mb-3">Email templates (preview)</h2>
|
||||
<p className="text-muted small mb-4">
|
||||
React previews matching CodeIgniter <code>Views/emails/*.php</code>. Laravel should render the same HTML for
|
||||
outgoing mail; this UI is for QA and copy review.
|
||||
</p>
|
||||
<div className="mb-3">
|
||||
<Link to="/app/administrator/parent-email-extractor" className="btn btn-outline-primary btn-sm">
|
||||
Parent email extractor tool
|
||||
</Link>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template</th>
|
||||
<th>CI view</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SLUGS.sort().map((slug) => {
|
||||
const row = EMAIL_TEMPLATE_REGISTRY[slug]
|
||||
return (
|
||||
<tr key={slug}>
|
||||
<td>{row.title}</td>
|
||||
<td>
|
||||
<code className="small">{row.ciView}</code>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<Link className="btn btn-sm btn-outline-secondary" to={`/app/administrator/email-templates/${slug}`}>
|
||||
Preview
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { apiUrl } from '../../lib/apiOrigin'
|
||||
|
||||
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
|
||||
|
||||
function normalizeEmail(e: string) {
|
||||
return e.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function extractEmailsFromCSVText(text: string) {
|
||||
const found = text.match(EMAIL_RE) || []
|
||||
return Array.from(new Set(found.map(normalizeEmail)))
|
||||
}
|
||||
|
||||
function extractEmailsFromTextarea(text: string) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
text
|
||||
.split(/\n|,|;|\t/)
|
||||
.map(normalizeEmail)
|
||||
.filter((x) => x && x.includes('@')),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function downloadCSV(filename: string, rows: string[]) {
|
||||
const csvContent = 'data:text/csv;charset=utf-8,' + rows.map((r) => `"${r.replace(/"/g, '""')}"`).join('\n')
|
||||
const encodedUri = encodeURI(csvContent)
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', encodedUri)
|
||||
link.setAttribute('download', filename)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin tool ported from `app/Views/emails/parent_email_extractor.php` (client-side compare; no jQuery DataTables).
|
||||
*/
|
||||
export function ParentEmailExtractorPage() {
|
||||
const defaultApi = useMemo(() => apiUrl('/api/emails'), [])
|
||||
const [apiUrlInput, setApiUrlInput] = useState(defaultApi)
|
||||
const [csvSummary, setCsvSummary] = useState('')
|
||||
const [compareSummary, setCompareSummary] = useState('')
|
||||
const [usersText, setUsersText] = useState('')
|
||||
const [parentsText, setParentsText] = useState('')
|
||||
const [csvEmails, setCsvEmails] = useState<Set<string>>(new Set())
|
||||
const [existed, setExisted] = useState<string[]>([])
|
||||
const [needToAdd, setNeedToAdd] = useState<{ email: string; source: string }[]>([])
|
||||
|
||||
const usersEmails = useMemo(() => extractEmailsFromTextarea(usersText), [usersText])
|
||||
const parentsEmails = useMemo(() => extractEmailsFromTextarea(parentsText), [parentsText])
|
||||
|
||||
const handleParseCsv = useCallback(async (file: File | undefined) => {
|
||||
if (!file) {
|
||||
window.alert('Please choose a CSV file.')
|
||||
return
|
||||
}
|
||||
const text = await file.text()
|
||||
const emails = extractEmailsFromCSVText(text)
|
||||
setCsvEmails(new Set(emails))
|
||||
setCsvSummary(`Found ${emails.length} unique emails in CSV.`)
|
||||
}, [])
|
||||
|
||||
const handleFetchApi = useCallback(async () => {
|
||||
const url = apiUrlInput.trim()
|
||||
if (!url) {
|
||||
window.alert('Enter your /api/emails endpoint URL.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = (await res.json()) as { users?: string[]; parents?: string[] }
|
||||
const users = (data.users ?? []).map(normalizeEmail).filter(Boolean)
|
||||
const parents = (data.parents ?? []).map(normalizeEmail).filter(Boolean)
|
||||
setUsersText(users.join('\n'))
|
||||
setParentsText(parents.join('\n'))
|
||||
window.alert(`Fetched ${users.length} users and ${parents.length} parents emails.`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.alert('Failed to fetch API. See console for details.')
|
||||
}
|
||||
}, [apiUrlInput])
|
||||
|
||||
const handleCompare = useCallback(() => {
|
||||
const u = new Set(usersEmails)
|
||||
const p = new Set(parentsEmails)
|
||||
const dbSet = new Set([...u, ...p])
|
||||
|
||||
const existedList = [...csvEmails].filter((e) => dbSet.has(e)).sort()
|
||||
const needEmails = [...dbSet].filter((e) => !csvEmails.has(e)).sort()
|
||||
const needRows = needEmails.map((e) => {
|
||||
const inUsers = u.has(e)
|
||||
const inParents = p.has(e)
|
||||
let source = ''
|
||||
if (inUsers && inParents) source = 'Users + Parents'
|
||||
else if (inUsers) source = 'Users'
|
||||
else if (inParents) source = 'Parents'
|
||||
return { email: e, source }
|
||||
})
|
||||
|
||||
setExisted(existedList)
|
||||
setNeedToAdd(needRows)
|
||||
setCompareSummary(
|
||||
`Compared CSV (${csvEmails.size}) vs DB (${dbSet.size} unique: ${u.size} users + ${p.size} parents).`,
|
||||
)
|
||||
}, [csvEmails, usersEmails, parentsEmails])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setCsvEmails(new Set())
|
||||
setCsvSummary('')
|
||||
setCompareSummary('')
|
||||
setUsersText('')
|
||||
setParentsText('')
|
||||
setApiUrlInput(defaultApi)
|
||||
setExisted([])
|
||||
setNeedToAdd([])
|
||||
}, [defaultApi])
|
||||
|
||||
return (
|
||||
<div className="container my-4">
|
||||
<h2 className="text-center mb-2">Parent Email Extractor</h2>
|
||||
<p className="text-center mb-4 text-muted">
|
||||
Upload a CSV file with emails, compare against your database emails, and see matches and gaps.
|
||||
</p>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title mb-3">1) Upload CSV of emails (source)</h5>
|
||||
<div className="row g-2 align-items-center">
|
||||
<div className="col-12 col-md-8">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="form-control"
|
||||
onChange={(ev) => {
|
||||
const f = ev.target.files?.[0]
|
||||
void handleParseCsv(f)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted small mt-2 mb-0">
|
||||
The CSV can be a single column of emails or multi-column. This tool extracts any values that look like
|
||||
emails.
|
||||
</p>
|
||||
{csvSummary ? <div className="text-muted small mt-2">{csvSummary}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title mb-3">2) Provide database emails</h5>
|
||||
<div className="mb-2">
|
||||
<span className="fw-semibold">Option A:</span> Paste emails (one per line)
|
||||
</div>
|
||||
<label className="form-label small text-muted">First parent's email</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={4}
|
||||
placeholder={'user1@example.com\nuser2@example.com'}
|
||||
value={usersText}
|
||||
onChange={(e) => setUsersText(e.target.value)}
|
||||
/>
|
||||
<label className="form-label small text-muted mt-3">Second parent's email</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={4}
|
||||
placeholder={'parent1@example.com\nparent2@example.com'}
|
||||
value={parentsText}
|
||||
onChange={(e) => setParentsText(e.target.value)}
|
||||
/>
|
||||
<div className="text-center text-muted my-2">— or —</div>
|
||||
<div className="mb-2">
|
||||
<span className="fw-semibold">Option B:</span> Fetch from API
|
||||
</div>
|
||||
<div className="row g-2 align-items-center">
|
||||
<div className="col-12 col-md-8">
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
value={apiUrlInput}
|
||||
onChange={(e) => setApiUrlInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-4 text-md-end">
|
||||
<button type="button" className="btn btn-outline-secondary w-100" onClick={() => void handleFetchApi()}>
|
||||
Fetch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted small mt-2 mb-0">
|
||||
Provide a backend endpoint that returns JSON like: <code>{'{"users": [...], "parents": [...]}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm mt-3">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title mb-3">3) Compare</h5>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
<button type="button" className="btn btn-primary" onClick={handleCompare}>
|
||||
Run Comparison
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={handleReset}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{compareSummary ? <div className="text-muted small mt-2">{compareSummary}</div> : null}
|
||||
|
||||
<div className="row g-3 mt-2">
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="p-3 border rounded-3 bg-light">
|
||||
<h6 className="d-flex align-items-center gap-2 mb-2">
|
||||
Existed emails <span className="badge bg-success-subtle text-success">{existed.length}</span>
|
||||
</h6>
|
||||
<div className="table-responsive" style={{ maxHeight: 320 }}>
|
||||
<table className="table table-striped table-hover table-sm w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{existed.map((em) => (
|
||||
<tr key={em}>
|
||||
<td>{em}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="d-flex justify-content-end mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={() => downloadCSV('existed_emails.csv', existed)}
|
||||
>
|
||||
Download CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="p-3 border rounded-3 bg-light">
|
||||
<h6 className="d-flex align-items-center gap-2 mb-2">
|
||||
Need to add email <span className="badge bg-danger-subtle text-danger">{needToAdd.length}</span>
|
||||
</h6>
|
||||
<div className="text-muted small mb-2">
|
||||
Emails present in database (users or parents) but <strong>NOT</strong> in your uploaded CSV.
|
||||
</div>
|
||||
<div className="table-responsive" style={{ maxHeight: 320 }}>
|
||||
<table className="table table-striped table-hover table-sm w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{needToAdd.map((row) => (
|
||||
<tr key={row.email}>
|
||||
<td>{row.email}</td>
|
||||
<td>{row.source}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="d-flex justify-content-end mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={() => downloadCSV('need_to_add_emails.csv', needToAdd.map((r) => r.email))}
|
||||
>
|
||||
Download CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert alert-info mt-3 mb-0 p-2">
|
||||
<ul className="mb-0 small">
|
||||
<li>Comparison is case-insensitive and trims whitespace.</li>
|
||||
<li>Duplicates are removed within each source before comparison.</li>
|
||||
<li>CSV parsing extracts any values that resemble valid email addresses from any column.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { formatLocalDate } from '../textUtils'
|
||||
|
||||
export function FinalWarningBody({
|
||||
parent_name = 'Parent/Guardian',
|
||||
student_name = 'your student',
|
||||
class_section_name,
|
||||
date_fmt,
|
||||
semester,
|
||||
school_year,
|
||||
}: {
|
||||
parent_name?: string
|
||||
student_name?: string
|
||||
class_section_name?: string
|
||||
date_fmt?: string
|
||||
semester?: string
|
||||
school_year?: string
|
||||
}) {
|
||||
return (
|
||||
<div style={{ fontSize: 16, fontFamily: 'Arial, Helvetica, sans-serif', color: '#333' }}>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>Dear {parent_name},</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
This is a <strong>final warning</strong> concerning the attendance of <strong>{student_name}</strong>
|
||||
{class_section_name ? ` (${class_section_name})` : ''}
|
||||
{date_fmt ? ` as of ${date_fmt}` : ''}. Continued unexcused absences
|
||||
{semester && school_year ? ` during ${semester} ${school_year}` : ''} may result in dismissal from the
|
||||
program.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
If there are special circumstances we should be aware of, please contact the school at{' '}
|
||||
<a href="mailto:alrahma.isgl@gmail.com">school email</a>.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
Sincerely,
|
||||
<br />
|
||||
Al Rahma Sunday School
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FollowUpBody(props: Parameters<typeof FinalWarningBody>[0]) {
|
||||
const {
|
||||
parent_name = 'Parent/Guardian',
|
||||
student_name = 'your student',
|
||||
class_section_name,
|
||||
date_fmt,
|
||||
semester,
|
||||
school_year,
|
||||
} = props
|
||||
return (
|
||||
<div style={{ fontSize: 16, fontFamily: 'Arial, Helvetica, sans-serif', color: '#333' }}>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>Dear {parent_name},</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
We're reaching out regarding <strong>{student_name}</strong>
|
||||
{class_section_name ? ` (${class_section_name})` : ''}
|
||||
{date_fmt ? ` on ${date_fmt}` : ''}. We noticed recent unexcused absences
|
||||
{semester && school_year ? ` during ${semester} ${school_year}` : ''}. Please let us know in advance
|
||||
about any upcoming absences.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
If there are special circumstances we should be aware of, please contact the school at{' '}
|
||||
<a href="mailto:alrahma.isgl@gmail.com">school email</a>.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
Thank you,
|
||||
<br />
|
||||
Al Rahma Sunday School
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DismissalBody(props: Parameters<typeof FinalWarningBody>[0]) {
|
||||
const {
|
||||
parent_name = 'Parent/Guardian',
|
||||
student_name = 'your student',
|
||||
class_section_name,
|
||||
date_fmt,
|
||||
semester,
|
||||
school_year,
|
||||
} = props
|
||||
return (
|
||||
<div style={{ fontSize: 16, fontFamily: 'Arial, Helvetica, sans-serif', color: '#333' }}>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>Dear {parent_name},</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
We regret to inform you that <strong>{student_name}</strong>
|
||||
{class_section_name ? ` (${class_section_name})` : ''} has been <strong>dismissed</strong>
|
||||
{semester && school_year ? ` for ${semester} ${school_year}` : ''}
|
||||
due to repeated unexcused absences{date_fmt ? ` as of ${date_fmt}` : ''}.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
If you believe this decision was made in error or wish to discuss next steps, please contact the school at{' '}
|
||||
<a href="mailto:alrahma.isgl@gmail.com">school email</a>.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
Sincerely,
|
||||
<br />
|
||||
Al Rahma Sunday School
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AttendanceStudentRow = {
|
||||
name?: string
|
||||
date?: string
|
||||
date_label?: string
|
||||
class_label?: string
|
||||
type_label?: string
|
||||
arrival_time?: string
|
||||
dismiss_time?: string
|
||||
}
|
||||
|
||||
export function ParentAttendanceAdminBody({
|
||||
recipient_parent = { firstname: 'Taylor', lastname: 'Jordan', email: 'parent@example.com', cellphone: '555-0100' },
|
||||
primary_parent,
|
||||
students = [
|
||||
{
|
||||
name: 'Ahmad Khan',
|
||||
date_label: '04-15-2026',
|
||||
class_label: 'Grade 5-A',
|
||||
type_label: 'Absent',
|
||||
arrival_time: '',
|
||||
dismiss_time: '',
|
||||
},
|
||||
] as AttendanceStudentRow[],
|
||||
type_label = 'Attendance Update',
|
||||
reason,
|
||||
semester = 'Fall',
|
||||
school_year = '2025-2026',
|
||||
submitted_at = 'April 15, 2026 10:22 AM',
|
||||
}: {
|
||||
recipient_parent?: Record<string, string | undefined>
|
||||
primary_parent?: Record<string, string | undefined>
|
||||
students?: AttendanceStudentRow[]
|
||||
type_label?: string
|
||||
reason?: string
|
||||
semester?: string
|
||||
school_year?: string
|
||||
submitted_at?: string
|
||||
}) {
|
||||
const submitterName =
|
||||
`${recipient_parent.firstname ?? ''} ${recipient_parent.lastname ?? ''}`.trim() || 'Parent/Guardian'
|
||||
const primaryName = primary_parent
|
||||
? `${primary_parent.firstname ?? ''} ${primary_parent.lastname ?? ''}`.trim()
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'Arial, Helvetica, sans-serif', fontSize: 15, color: '#333', lineHeight: 1.5 }}>
|
||||
<p>
|
||||
<strong>Parent Submission Received</strong>
|
||||
</p>
|
||||
<p>
|
||||
{submitterName} submitted an {type_label.toLowerCase()} request:
|
||||
</p>
|
||||
{students.length > 0 ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', margin: '12px 0', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Student</th>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Date</th>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Class</th>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map((student, idx) => {
|
||||
const dateDisplay =
|
||||
student.date_label ||
|
||||
(student.date ? formatLocalDate(student.date) : '—')
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>{student.name ?? 'Student'}</td>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>{dateDisplay}</td>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>
|
||||
{student.class_label ?? 'Not Assigned'}
|
||||
</td>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>
|
||||
{student.type_label ?? ''}
|
||||
<br />
|
||||
{student.arrival_time ? <>Arrive ~ {student.arrival_time}<br /></> : null}
|
||||
{student.dismiss_time ? <>Pickup @ {student.dismiss_time}<br /></> : null}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
{reason ? (
|
||||
<p>
|
||||
<strong>Reason:</strong> {reason}
|
||||
</p>
|
||||
) : null}
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<strong>Contact Info</strong>
|
||||
</p>
|
||||
<ul style={{ paddingLeft: 20, marginTop: 0 }}>
|
||||
<li>Submitter Email: {recipient_parent.email ?? 'N/A'}</li>
|
||||
{recipient_parent.cellphone ? <li>Submitter Phone: {recipient_parent.cellphone}</li> : null}
|
||||
{primaryName && primaryName !== submitterName ? (
|
||||
<li>
|
||||
Primary Parent: {primaryName} ({primary_parent?.email ?? 'N/A'})
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
{(semester || school_year) && (
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Semester/Year: {`${semester ?? ''} ${school_year ?? ''}`.trim()}
|
||||
</p>
|
||||
)}
|
||||
{submitted_at ? <p style={{ marginTop: 0 }}>Submitted: {submitted_at}</p> : null}
|
||||
<p style={{ marginTop: 20, fontSize: 14, color: '#555' }}>
|
||||
Logged automatically by the Parent Attendance Report form.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ParentAttendanceParentBody({
|
||||
recipient_parent = { firstname: 'Taylor', lastname: 'Jordan' },
|
||||
students = [
|
||||
{
|
||||
name: 'Ahmad Khan',
|
||||
date_label: '04-15-2026',
|
||||
class_label: 'Grade 5-A',
|
||||
type_label: 'Absent',
|
||||
},
|
||||
] as AttendanceStudentRow[],
|
||||
type_label = 'Attendance Update',
|
||||
reason,
|
||||
semester = 'Fall',
|
||||
school_year = '2025-2026',
|
||||
submitted_at = 'April 15, 2026 10:22 AM',
|
||||
}: {
|
||||
recipient_parent?: Record<string, string | undefined>
|
||||
students?: AttendanceStudentRow[]
|
||||
type_label?: string
|
||||
reason?: string
|
||||
semester?: string
|
||||
school_year?: string
|
||||
submitted_at?: string
|
||||
}) {
|
||||
let parentName = `${recipient_parent.firstname ?? ''} ${recipient_parent.lastname ?? ''}`.trim()
|
||||
if (!parentName) parentName = 'Parent/Guardian'
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'Arial, Helvetica, sans-serif', fontSize: 15, color: '#333', lineHeight: 1.5 }}>
|
||||
<p>Dear {parentName},</p>
|
||||
<p>
|
||||
Thank you for letting us know about your child's {type_label.toLowerCase()}. We have recorded your
|
||||
submission.
|
||||
</p>
|
||||
{students.length > 0 ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', margin: '12px 0', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Student</th>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Date</th>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Class</th>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map((student, idx) => (
|
||||
<tr key={idx}>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>{student.name ?? 'Student'}</td>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>
|
||||
{student.date_label || (student.date ? formatLocalDate(student.date) : '—')}
|
||||
</td>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>
|
||||
{student.class_label ?? 'Not Assigned'}
|
||||
</td>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>
|
||||
{student.type_label ?? ''}
|
||||
<br />
|
||||
{student.arrival_time ? <>Arrive ~ {student.arrival_time}<br /></> : null}
|
||||
{student.dismiss_time ? <>Pickup @ {student.dismiss_time}<br /></> : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
{reason ? (
|
||||
<p>
|
||||
<strong>Reason provided:</strong> {reason}
|
||||
</p>
|
||||
) : null}
|
||||
{(semester || school_year) && (
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Semester/Year: {`${semester ?? ''} ${school_year ?? ''}`.trim()}
|
||||
</p>
|
||||
)}
|
||||
{submitted_at ? <p style={{ marginTop: 0 }}>Submitted: {submitted_at}</p> : null}
|
||||
<p>
|
||||
Our admin team and your child's teacher will be notified. If you need to make a change, please update
|
||||
the form before the cutoff time or contact the school office.
|
||||
</p>
|
||||
<p style={{ marginTop: 20 }}>
|
||||
Jazakum Allahu khairan,
|
||||
<br />
|
||||
Al Rahma Sunday School
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BelowSixtyPerformanceBody({
|
||||
parent_name = 'Parent/Guardian',
|
||||
student_name = 'your student',
|
||||
class_section_name = 'Grade 5-A',
|
||||
semester = 'Fall',
|
||||
school_year = '2025-2026',
|
||||
scores = {
|
||||
homework_avg: 55,
|
||||
project_avg: 58,
|
||||
participation_score: 60,
|
||||
test_avg: 52,
|
||||
ptap_score: 59,
|
||||
attendance_score: 70,
|
||||
midterm_exam_score: 48,
|
||||
semester_score: 55,
|
||||
} as Record<string, number | string | null | undefined>,
|
||||
comment = 'Please reach out to discuss support options.',
|
||||
}: {
|
||||
parent_name?: string
|
||||
student_name?: string
|
||||
class_section_name?: string
|
||||
semester?: string
|
||||
school_year?: string
|
||||
scores?: Record<string, number | string | null | undefined>
|
||||
comment?: string
|
||||
}) {
|
||||
const rows: [string, keyof typeof scores][] = [
|
||||
['Homework Avg', 'homework_avg'],
|
||||
['Project Avg', 'project_avg'],
|
||||
['Participation', 'participation_score'],
|
||||
['Test Avg', 'test_avg'],
|
||||
['PTAP Score', 'ptap_score'],
|
||||
['Attendance', 'attendance_score'],
|
||||
['Midterm Score', 'midterm_exam_score'],
|
||||
['Semester Score', 'semester_score'],
|
||||
]
|
||||
return (
|
||||
<div style={{ fontSize: 16, fontFamily: 'Arial, Helvetica, sans-serif', color: '#333' }}>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>Dear {parent_name},</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
We are reaching out about <strong>{student_name}</strong>
|
||||
{class_section_name ? ` (${class_section_name})` : ''}
|
||||
{semester || school_year ? ` for ${`${semester ?? ''} ${school_year ?? ''}`.trim()}.` : '.'} The current
|
||||
semester performance is below 60.
|
||||
</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', margin: '12px 0', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ border: '1px solid #ddd', padding: 6, textAlign: 'left' }}>Item</th>
|
||||
<th style={{ border: '1px solid #ddd', padding: 6, textAlign: 'left' }}>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([label, key]) => {
|
||||
const val = scores[key]
|
||||
const display =
|
||||
val === null || val === undefined || val === ''
|
||||
? '—'
|
||||
: typeof val === 'number'
|
||||
? val.toFixed(2)
|
||||
: String(val)
|
||||
return (
|
||||
<tr key={label}>
|
||||
<td style={{ border: '1px solid #ddd', padding: 6 }}>{label}</td>
|
||||
<td style={{ border: '1px solid #ddd', padding: 6 }}>{display}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{comment ? (
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
<strong>Teacher Comment:</strong> {comment}
|
||||
</p>
|
||||
) : null}
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.5 }}>Please contact the school to discuss support plans for your child.</p>
|
||||
<p style={{ margin: 0, lineHeight: 1.5 }}>
|
||||
Thank you,
|
||||
<br />
|
||||
Al Rahma Sunday School
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
import { joinNamesAnd, normalizeStudentNames, PORTAL_HREF } from '../textUtils'
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
display: 'inline-block',
|
||||
padding: '10px 16px',
|
||||
textDecoration: 'none',
|
||||
borderRadius: 6,
|
||||
background: '#198754',
|
||||
color: '#ffffff',
|
||||
fontWeight: 600,
|
||||
fontSize: 16,
|
||||
}
|
||||
|
||||
function AccessAccount() {
|
||||
return (
|
||||
<p style={{ margin: '16px 0' }}>
|
||||
<a href={PORTAL_HREF} target="_blank" rel="noopener noreferrer" style={btnStyle}>
|
||||
Access My Account
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusAdmissionReviewBody({
|
||||
parentName = 'Parent',
|
||||
studentName = ['Sara Ali', 'Omar Ali'] as string | string[],
|
||||
}: {
|
||||
parentName?: string
|
||||
studentName?: string | string[]
|
||||
}) {
|
||||
const multi = Array.isArray(studentName) && studentName.length > 1
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ fontSize: 22, fontWeight: 'bold' }}>Admission Under Review</h2>
|
||||
<p style={{ fontSize: 16 }}>Dear {parentName},</p>
|
||||
{multi ? (
|
||||
<>
|
||||
<p style={{ fontSize: 16 }}>
|
||||
Thank you for submitting the enrollment application for the following {studentName.length} students:
|
||||
</p>
|
||||
<ul style={{ fontSize: 16 }}>
|
||||
{(studentName as string[]).map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontSize: 16 }}>
|
||||
Thank you for submitting the enrollment application for{' '}
|
||||
{Array.isArray(studentName) ? studentName[0] : studentName}.
|
||||
</p>
|
||||
)}
|
||||
<p style={{ fontSize: 16 }}>
|
||||
Your application{multi ? 's are' : ' is'} currently under review by our admissions committee. This
|
||||
process typically takes 1–2 business days.
|
||||
</p>
|
||||
<p style={{ fontSize: 16 }}>You will be notified immediately once a decision has been made.</p>
|
||||
<p style={{ fontSize: 16 }}>You can check your application status at any time through the parent portal.</p>
|
||||
<AccessAccount />
|
||||
<p style={{ fontSize: 16 }}>
|
||||
Best regards,
|
||||
<br />
|
||||
Admissions Team
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusDeniedBody({
|
||||
parentName = 'Parent',
|
||||
studentName = ['Sara Ali', 'Omar Ali'] as string | string[],
|
||||
students,
|
||||
}: {
|
||||
parentName?: string
|
||||
studentName?: string | string[]
|
||||
students?: { name?: string; reason?: string }[]
|
||||
}) {
|
||||
const names = normalizeStudentNames(
|
||||
Array.isArray(studentName) ? studentName : studentName ? [studentName] : [],
|
||||
)
|
||||
const isMulti = names.length > 1
|
||||
const studentLabel =
|
||||
names.length === 0
|
||||
? 'your student'
|
||||
: names.length === 1
|
||||
? names[0]!
|
||||
: names.length === 2
|
||||
? `${names[0]} and ${names[1]}`
|
||||
: `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`
|
||||
|
||||
const hasReasons = students?.some((s) => s.reason)
|
||||
|
||||
return (
|
||||
<div style={{ fontSize: 16, lineHeight: 1.5 }}>
|
||||
<h2>Admission Decision</h2>
|
||||
<p>Dear {parentName},</p>
|
||||
{isMulti ? (
|
||||
<>
|
||||
<p>
|
||||
Thank you for your interest in our school and for applying for admission for the following {names.length}{' '}
|
||||
students:
|
||||
</p>
|
||||
<ul style={{ paddingLeft: 20 }}>
|
||||
{names.map((n, i) => (
|
||||
<li key={i}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>After careful consideration, we regret to inform you that we are unable to grant admission at this time.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Thank you for your interest in our school and for applying for {studentLabel}'s admission.</p>
|
||||
<p>After careful consideration, we regret to inform you that we are unable to grant admission at this time.</p>
|
||||
</>
|
||||
)}
|
||||
{hasReasons && students ? (
|
||||
<>
|
||||
<p>Where available, we've included brief context for your reference:</p>
|
||||
<ul style={{ paddingLeft: 20 }}>
|
||||
{students.map((s, i) => (
|
||||
<li key={i}>
|
||||
<strong>{s.name ?? ''}:</strong> {s.reason ?? 'No additional details provided.'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
<p>This decision was made after a thorough review of all applications received, and we acknowledge that it may be disappointing.</p>
|
||||
<p>We encourage you to consider applying again in the future as circumstances may change.</p>
|
||||
<p>
|
||||
If you would like feedback on your application, please contact us at{' '}
|
||||
<a href="mailto:alrahma.isgl@gmail.com">alrahma.isgl@gmail.com</a>.
|
||||
</p>
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Admissions Committee
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type EnrolledStudent = {
|
||||
name?: string
|
||||
studentId?: string
|
||||
gradeLevel?: string
|
||||
teacherName?: string
|
||||
startDate?: string
|
||||
}
|
||||
|
||||
export function StatusEnrolledBody({
|
||||
parentName = 'Parent',
|
||||
schoolYear = '2025-2026',
|
||||
portalLink = PORTAL_HREF,
|
||||
students,
|
||||
studentName,
|
||||
studentId,
|
||||
gradeLevel,
|
||||
teacherName,
|
||||
startDate,
|
||||
}: {
|
||||
parentName?: string
|
||||
schoolYear?: string
|
||||
portalLink?: string
|
||||
students?: EnrolledStudent[]
|
||||
studentName?: string | string[]
|
||||
studentId?: string
|
||||
gradeLevel?: string
|
||||
teacherName?: string
|
||||
startDate?: string
|
||||
}) {
|
||||
let studentsArr: EnrolledStudent[] = students ?? []
|
||||
if (studentsArr.length === 0 && studentName) {
|
||||
const n = Array.isArray(studentName) ? studentName[0] : studentName
|
||||
if (n)
|
||||
studentsArr = [
|
||||
{ name: n, studentId, gradeLevel, teacherName, startDate },
|
||||
]
|
||||
}
|
||||
studentsArr = studentsArr.filter((s) => (s.name ?? '').trim() !== '')
|
||||
const isMulti = studentsArr.length > 1
|
||||
const fmt = (d?: string) => {
|
||||
if (!d) return null
|
||||
const t = Date.parse(d)
|
||||
return Number.isNaN(t) ? d : new Date(t).toLocaleDateString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontSize: 18, lineHeight: 1.6 }}>
|
||||
<h2>Enrollment Confirmed</h2>
|
||||
<p>Dear {parentName},</p>
|
||||
{isMulti ? (
|
||||
<>
|
||||
<p>
|
||||
We are pleased to inform you that the following {studentsArr.length} students are now officially enrolled for
|
||||
the {schoolYear} school year:
|
||||
</p>
|
||||
<ul style={{ margin: '0 0 16px 20px' }}>
|
||||
{studentsArr.map((st, i) => {
|
||||
const bits = [
|
||||
st.gradeLevel ? `Grade: ${st.gradeLevel}` : null,
|
||||
st.teacherName ? `Teacher: ${st.teacherName}` : null,
|
||||
fmt(st.startDate) ? `Start: ${fmt(st.startDate)}` : null,
|
||||
].filter(Boolean)
|
||||
return (
|
||||
<li key={i}>
|
||||
<strong>{st.name}</strong>
|
||||
{bits.length > 0 ? <span style={{ color: '#6c757d' }}> ({bits.join(' • ')})</span> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
We are pleased to inform you that <strong>{studentsArr[0]?.name ?? 'your student'}</strong> is now officially
|
||||
enrolled for the {schoolYear} school year.
|
||||
</p>
|
||||
)}
|
||||
<p style={{ margin: '16px 0' }}>
|
||||
<a href={portalLink} target="_blank" rel="noopener noreferrer" style={{ ...btnStyle, fontSize: 18, padding: '12px 18px' }}>
|
||||
Access My Account
|
||||
</a>
|
||||
</p>
|
||||
<p>Welcome to our school community!</p>
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Admissions Team
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusNotEnrolledBody({
|
||||
parentName = 'Parent',
|
||||
studentName = 'Sara Ali',
|
||||
portalLink = PORTAL_HREF,
|
||||
}: {
|
||||
parentName?: string
|
||||
studentName?: string
|
||||
portalLink?: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h2>Enrollment Not Started</h2>
|
||||
<p>Dear {parentName},</p>
|
||||
<p>We noticed that you haven't started the enrollment process for {studentName} yet.</p>
|
||||
<p>To begin the enrollment process, please log in to your parent portal and complete the application form.</p>
|
||||
<p>
|
||||
<a href={portalLink} target="_blank" rel="noopener noreferrer" style={btnStyle}>
|
||||
Access Parent Portal
|
||||
</a>
|
||||
</p>
|
||||
<p>If you need any assistance, please contact our admissions office.</p>
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Admissions Team
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPaymentPendingBody({
|
||||
parentName = 'Parent',
|
||||
studentName = ['Sara Ali', 'Omar Ali'] as string | string[],
|
||||
}: {
|
||||
parentName?: string
|
||||
studentName?: string | string[]
|
||||
}) {
|
||||
const displayNames = Array.isArray(studentName)
|
||||
? studentName.map((n) => String(n).trim()).filter(Boolean)
|
||||
: normalizeStudentNames(studentName)
|
||||
|
||||
const verb = displayNames.length > 1 ? 'have' : 'has'
|
||||
|
||||
return (
|
||||
<div style={{ fontSize: 16, lineHeight: 1.5 }}>
|
||||
<p>Dear {parentName},</p>
|
||||
{displayNames.length > 1 ? (
|
||||
<>
|
||||
<p>Congratulations! We are pleased to inform you that the following students have been accepted for admission to our school:</p>
|
||||
<table role="presentation" cellPadding={0} cellSpacing={0} style={{ borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{displayNames.map((n, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ width: 24, fontSize: 0, lineHeight: 0 }}> </td>
|
||||
<td style={{ fontSize: 16, lineHeight: 1.5, padding: '0 8px 2px 0', verticalAlign: 'top' }}>•</td>
|
||||
<td style={{ fontSize: 16, lineHeight: 1.5, padding: '0 0 2px 0', verticalAlign: 'top' }}>{n}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
We are excited to welcome your family to our community and look forward to supporting your children's
|
||||
growth in both faith and learning.
|
||||
</p>
|
||||
</>
|
||||
) : displayNames.length === 1 ? (
|
||||
<>
|
||||
<p>
|
||||
Congratulations! We are pleased to inform you that {displayNames[0]} {verb} been accepted for admission to
|
||||
our school.
|
||||
</p>
|
||||
<p>
|
||||
We are excited to welcome your family to our community and look forward to supporting your child's growth
|
||||
in both faith and learning.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>Congratulations! We are pleased to inform you that your student has been accepted for admission to our school.</p>
|
||||
)}
|
||||
<p>
|
||||
<strong>Next steps:</strong> Please log in to the parent portal and visit the <em>Invoice</em> tab to review your
|
||||
invoice details. The balance must be paid in person on the first day of school to complete the enrollment process.
|
||||
</p>
|
||||
<AccessAccount />
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Admissions Committee
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusRefundPendingBody({
|
||||
parentName = 'Parent',
|
||||
studentName = ['Sara Ali'] as string | string[],
|
||||
refundAmount = '$150.00',
|
||||
}: {
|
||||
parentName?: string
|
||||
studentName?: string | string[]
|
||||
refundAmount?: string
|
||||
}) {
|
||||
const multi = Array.isArray(studentName) && studentName.length > 1
|
||||
return (
|
||||
<>
|
||||
<h2>Refund Processing</h2>
|
||||
<p>Dear {parentName},</p>
|
||||
{multi ? (
|
||||
<>
|
||||
<p>Your withdrawal requests for the following {studentName.length} students have been approved:</p>
|
||||
<ul style={{ paddingLeft: 20 }}>
|
||||
{(studentName as string[]).map((name, i) => (
|
||||
<li key={i}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Your withdrawal request for {Array.isArray(studentName) ? studentName[0] : studentName ?? 'your student'} has
|
||||
been approved.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{refundAmount ? `A refund in the amount of ${refundAmount} is being processed.` : 'A refund is being processed.'}{' '}
|
||||
Please allow 7–10 business days for the refund to be issued to your original payment method.
|
||||
</p>
|
||||
<p>You will receive a confirmation email once the refund has been processed.</p>
|
||||
<p>If you have any questions, please contact our billing department.</p>
|
||||
<AccessAccount />
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Billing Department
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusWaitlistBody({
|
||||
parentName = 'Parent',
|
||||
studentName = ['Sara Ali', 'Omar Ali'] as string | string[],
|
||||
}: {
|
||||
parentName?: string
|
||||
studentName?: string | string[]
|
||||
}) {
|
||||
const names = Array.isArray(studentName)
|
||||
? studentName
|
||||
: studentName
|
||||
? [studentName]
|
||||
: []
|
||||
let isMulti = names.length > 1
|
||||
let studentLabel = joinNamesAnd(names) || 'your student'
|
||||
if (names.length === 1) {
|
||||
isMulti = false
|
||||
studentLabel = names[0]!
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontSize: 16, lineHeight: 1.5 }}>
|
||||
<p>Dear {parentName},</p>
|
||||
{isMulti ? (
|
||||
<>
|
||||
<p>
|
||||
Thank you for your interest in our school and for applying for admission for the following {names.length}{' '}
|
||||
students:
|
||||
</p>
|
||||
<ul style={{ paddingLeft: 20 }}>
|
||||
{names.map((n, i) => (
|
||||
<li key={i}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>
|
||||
After careful review, we are unable to grant immediate admission at this time. However, we are pleased to
|
||||
place these students on our waitlist for the upcoming school year. Should seats become available, we will
|
||||
notify you right away.
|
||||
</p>
|
||||
<p>
|
||||
We understand this may not be the outcome you had hoped for, but please know that your applications remain
|
||||
active and under consideration. In the meantime, you may check the status of your applications at any time
|
||||
through the parent portal.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Thank you for your interest in our school and for applying for {studentLabel}'s admission.</p>
|
||||
<p>
|
||||
After careful review, we are unable to grant immediate admission at this time. However, we are pleased to
|
||||
place {studentLabel} on our waitlist for the upcoming school year. Should a seat become available, we will
|
||||
notify you right away.
|
||||
</p>
|
||||
<p>
|
||||
We understand this may not be the outcome you had hoped for, but please know that the application remains
|
||||
active and under consideration. In the meantime, you may check the status of the application at any time
|
||||
through the parent portal.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<AccessAccount />
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Admissions Committee
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusWithdrawReviewBody({
|
||||
parentName = 'Parent',
|
||||
studentName = ['Sara Ali'] as string | string[],
|
||||
}: {
|
||||
parentName?: string
|
||||
studentName?: string | string[]
|
||||
}) {
|
||||
const multi = Array.isArray(studentName) && studentName.length > 1
|
||||
return (
|
||||
<>
|
||||
<h2>Withdrawal Request Under Review</h2>
|
||||
<p>Dear {parentName},</p>
|
||||
{multi ? (
|
||||
<>
|
||||
<p>We have received your request to withdraw the following {studentName.length} students from our school:</p>
|
||||
<ul style={{ paddingLeft: 20, fontSize: 14 }}>
|
||||
{(studentName as string[]).map((n, i) => (
|
||||
<li key={i}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
We have received your request to withdraw {Array.isArray(studentName) ? studentName[0] : studentName ?? 'your student'} from our school.
|
||||
</p>
|
||||
)}
|
||||
<p>This request is currently under review by our administration. We will contact you within 3–5 business days regarding the next steps.</p>
|
||||
<p>If you have any questions or need to provide additional information, please contact our registrar's office.</p>
|
||||
<AccessAccount />
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Registrar's Office
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type WithdrawRow = { name?: string; withdrawalDate?: string }
|
||||
|
||||
export function StatusWithdrawnBody({
|
||||
parentName = 'Parent',
|
||||
studentName = 'Sara Ali',
|
||||
withdrawalDate = 'June 1, 2026',
|
||||
withdrawals,
|
||||
}: {
|
||||
parentName?: string
|
||||
studentName?: string | string[]
|
||||
withdrawalDate?: string
|
||||
withdrawals?: WithdrawRow[]
|
||||
}) {
|
||||
let farewell: string
|
||||
if (withdrawals && withdrawals.length > 0) {
|
||||
farewell =
|
||||
joinNamesAnd(
|
||||
withdrawals.map((w) => w.name).filter((n): n is string => Boolean(n && String(n).trim())),
|
||||
) || 'your student'
|
||||
} else if (Array.isArray(studentName)) {
|
||||
const n = studentName.map((x) => String(x).trim()).filter(Boolean)
|
||||
farewell = joinNamesAnd(n) || 'your student'
|
||||
} else {
|
||||
farewell = studentName ?? 'your student'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Withdrawal Complete</h2>
|
||||
<p>Dear {parentName},</p>
|
||||
{withdrawals && withdrawals.length > 0 ? (
|
||||
<>
|
||||
<p>This email confirms the following students have been officially withdrawn from our school:</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Student</th>
|
||||
<th style={{ textAlign: 'left', borderBottom: '1px solid #ddd', padding: 6 }}>Effective Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{withdrawals.map((w, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>{w.name ?? ''}</td>
|
||||
<td style={{ padding: 6, borderBottom: '1px solid #f1f1f1' }}>{w.withdrawalDate ?? ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : Array.isArray(studentName) && studentName.length > 1 ? (
|
||||
<>
|
||||
<p>
|
||||
This email confirms the following {studentName.length} students have been officially withdrawn from our school
|
||||
{withdrawalDate ? ` effective ${withdrawalDate}` : ''}:
|
||||
</p>
|
||||
<ul style={{ paddingLeft: 20 }}>
|
||||
{studentName.map((n, i) => (
|
||||
<li key={i}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
This email confirms that {Array.isArray(studentName) ? studentName[0] : studentName} has been officially withdrawn from our school
|
||||
{withdrawalDate ? ` effective ${withdrawalDate}` : ''}.
|
||||
</p>
|
||||
)}
|
||||
<p>The refund process has been completed. You should see the credit in your account within 3–5 business days.</p>
|
||||
<p>We're sorry to see you go and wish {farewell} all the best in their future educational endeavors.</p>
|
||||
<p>If you would like to re-enroll in the future, please don't hesitate to contact us.</p>
|
||||
<AccessAccount />
|
||||
<p>
|
||||
Best regards,
|
||||
<br />
|
||||
Registrar's Office
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type PaymentStudent = { firstname?: string; lastname?: string }
|
||||
|
||||
export function PaymentReceiptBody({
|
||||
parentName = 'Parent',
|
||||
invoiceNumber = 'INV-1001',
|
||||
invoiceId = '1001',
|
||||
transactionId = 'TXN-ABC123',
|
||||
paymentDate = 'April 10, 2026',
|
||||
method = 'Check',
|
||||
checkNumber = '1024',
|
||||
installmentSeq = 2,
|
||||
amount = '$250.00',
|
||||
invoiceDiscount,
|
||||
invoiceTotal = '$1,200.00',
|
||||
preBalance = '$500.00',
|
||||
postBalance = '$250.00',
|
||||
students = [{ firstname: 'Sara', lastname: 'Ali' }] as PaymentStudent[],
|
||||
portalLink = PORTAL_HREF,
|
||||
}: {
|
||||
parentName?: string
|
||||
invoiceNumber?: string | number
|
||||
invoiceId?: string | number
|
||||
transactionId?: string
|
||||
paymentDate?: string
|
||||
method?: string
|
||||
checkNumber?: string
|
||||
installmentSeq?: number | string
|
||||
amount?: string
|
||||
invoiceDiscount?: string
|
||||
invoiceTotal?: string
|
||||
preBalance?: string
|
||||
postBalance?: string
|
||||
students?: PaymentStudent[]
|
||||
portalLink?: string
|
||||
}) {
|
||||
const invDisplay =
|
||||
invoiceNumber !== undefined && invoiceNumber !== '' ? String(invoiceNumber) : String(invoiceId ?? '')
|
||||
return (
|
||||
<div style={{ fontSize: 16, fontFamily: 'Arial, Helvetica, sans-serif', color: '#333' }}>
|
||||
<p>Dear {parentName},</p>
|
||||
<p>We have received your payment. Below are the details of your transaction:</p>
|
||||
<table
|
||||
border={0}
|
||||
cellPadding={8}
|
||||
cellSpacing={0}
|
||||
style={{ borderCollapse: 'collapse', width: '100%', fontSize: 16 }}
|
||||
>
|
||||
<tbody>
|
||||
<tr style={{ backgroundColor: '#f5f5f5' }}>
|
||||
<td>
|
||||
<strong>Invoice Number:</strong>
|
||||
</td>
|
||||
<td>#{invDisplay}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Transaction ID:</strong>
|
||||
</td>
|
||||
<td>{transactionId}</td>
|
||||
</tr>
|
||||
<tr style={{ backgroundColor: '#f5f5f5' }}>
|
||||
<td>
|
||||
<strong>Payment Date:</strong>
|
||||
</td>
|
||||
<td>{paymentDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Payment Method:</strong>
|
||||
</td>
|
||||
<td>
|
||||
{method}
|
||||
{checkNumber ? ` (Check #${checkNumber})` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ backgroundColor: '#f5f5f5' }}>
|
||||
<td>
|
||||
<strong>Installment #:</strong>
|
||||
</td>
|
||||
<td>{String(installmentSeq)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Amount Paid:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong style={{ color: 'green' }}>{amount}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{invoiceDiscount ? (
|
||||
<tr style={{ backgroundColor: '#f5f5f5' }}>
|
||||
<td>
|
||||
<strong>Discount Applied (This Invoice):</strong>
|
||||
</td>
|
||||
<td>{invoiceDiscount}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr style={{ backgroundColor: '#f5f5f5' }}>
|
||||
<td>
|
||||
<strong>Total Invoice Amount:</strong>
|
||||
</td>
|
||||
<td>{invoiceTotal}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Previous Balance:</strong>
|
||||
</td>
|
||||
<td>{preBalance}</td>
|
||||
</tr>
|
||||
<tr style={{ backgroundColor: '#f5f5f5' }}>
|
||||
<td>
|
||||
<strong>New Balance:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{postBalance}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{students.length > 0 ? (
|
||||
<>
|
||||
<p style={{ marginTop: 20 }}>
|
||||
<strong>Students linked to this invoice:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
{students.map((s, i) => (
|
||||
<li key={i}>{`${s.firstname ?? ''} ${s.lastname ?? ''}`.trim()}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
<p style={{ marginTop: 16 }}>You can view this invoice and your full payment history in your account:</p>
|
||||
<p>
|
||||
<a
|
||||
href={portalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '10px 16px',
|
||||
textDecoration: 'none',
|
||||
borderRadius: 6,
|
||||
background: '#198754',
|
||||
color: '#ffffff',
|
||||
fontWeight: 600,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Access My Account
|
||||
</a>
|
||||
</p>
|
||||
<p>Thank you for your payment and continued support.</p>
|
||||
<p style={{ marginTop: 20 }}>
|
||||
Warm regards,
|
||||
<br />
|
||||
<strong>Al Rahma Sunday School</strong>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExtraChargeNoticeBody({
|
||||
title = 'Account Charge Update',
|
||||
parentName = 'Parent',
|
||||
schoolYear = '2025-2026',
|
||||
semester = 'Fall',
|
||||
typeLabel = 'Fee adjustment',
|
||||
chargeTitle = 'Materials fee',
|
||||
chargeDesc = 'Additional materials for semester projects.',
|
||||
amountSigned = '-$25.00',
|
||||
amountAbs = '$25.00',
|
||||
invoiceTotal = '$1,225.00',
|
||||
preBalance = '$500.00',
|
||||
postBalance = '$525.00',
|
||||
invoiceNumber = 'INV-1001',
|
||||
invoiceId = '1001',
|
||||
dueDate = 'May 1, 2026',
|
||||
createdAt = 'April 12, 2026',
|
||||
portalLink = PORTAL_HREF,
|
||||
students = [{ name: 'Sara Ali', class: 'Grade 5-A' }],
|
||||
}: {
|
||||
title?: string
|
||||
parentName?: string
|
||||
schoolYear?: string
|
||||
semester?: string
|
||||
typeLabel?: string
|
||||
chargeTitle?: string
|
||||
chargeDesc?: string
|
||||
amountSigned?: string
|
||||
amountAbs?: string
|
||||
invoiceTotal?: string
|
||||
preBalance?: string
|
||||
postBalance?: string
|
||||
invoiceNumber?: string
|
||||
invoiceId?: string | number
|
||||
dueDate?: string
|
||||
createdAt?: string
|
||||
portalLink?: string
|
||||
students?: { name?: string; class?: string }[]
|
||||
}) {
|
||||
return (
|
||||
<div style={{ fontFamily: 'Arial, Helvetica, sans-serif', fontSize: 14, color: '#212529', lineHeight: 1.5 }}>
|
||||
<h2 style={{ margin: '0 0 10px', fontSize: 18 }}>{title}</h2>
|
||||
<p style={{ margin: '0 0 12px' }}>Hello {parentName},</p>
|
||||
<p style={{ margin: '0 0 12px' }}>
|
||||
An update was made to your account for{' '}
|
||||
<strong>
|
||||
{schoolYear} {semester ? `(${semester})` : ''}
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
<table cellPadding={0} cellSpacing={0} width="100%" style={{ borderCollapse: 'collapse', margin: '8px 0 16px' }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa', width: '35%' }}>Type</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>{typeLabel}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>Title</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>{chargeTitle}</td>
|
||||
</tr>
|
||||
{chargeDesc ? (
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>Details</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', whiteSpace: 'pre-wrap' }}>
|
||||
{chargeDesc}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>Amount</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>
|
||||
<strong>{amountSigned}</strong> (absolute: {amountAbs})
|
||||
</td>
|
||||
</tr>
|
||||
{dueDate ? (
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>Due Date</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>{dueDate}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>Invoice</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>
|
||||
{invoiceNumber || `INV-${invoiceId}`}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>Invoice Total</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>{invoiceTotal}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>Previous Balance</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>{preBalance}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>New Balance</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>
|
||||
<strong>{postBalance}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{createdAt ? (
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef', background: '#f8f9fa' }}>Recorded At</td>
|
||||
<td style={{ padding: '8px 10px', border: '1px solid #e9ecef' }}>{createdAt}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style={{ margin: '0 0 18px' }}>
|
||||
<a href={portalLink} target="_blank" rel="noopener noreferrer" style={btnStyle}>
|
||||
Access My Account
|
||||
</a>
|
||||
</p>
|
||||
{students.length > 0 ? (
|
||||
<>
|
||||
<p style={{ margin: '16px 0 8px' }}>
|
||||
<strong>Students on your account:</strong>
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{students.map((s, i) => (
|
||||
<li key={i}>
|
||||
{s.name}
|
||||
{s.class ? ` - ${s.class}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
<p style={{ margin: '18px 0 6px' }}>Thank you,</p>
|
||||
<p style={{ marginTop: 20 }}>
|
||||
Warm regards,
|
||||
<br />
|
||||
<strong>Al Rahma Sunday School</strong>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { PORTAL_HREF } from '../textUtils'
|
||||
|
||||
export function WelcomeParentBody({
|
||||
recipientName = 'Parent',
|
||||
orgName = 'Al Rahma Sunday School',
|
||||
activationLink = `${PORTAL_HREF}/login`,
|
||||
contactInfo = 'alrahma.isgl@gmail.com',
|
||||
}: {
|
||||
recipientName?: string
|
||||
orgName?: string
|
||||
activationLink?: string
|
||||
contactInfo?: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 18 }}>Dear {recipientName},</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
Thank you for creating an account with us — we're thrilled to welcome you to{' '}
|
||||
<strong>{orgName}</strong> community!
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>To activate your account, click the link below:</p>
|
||||
<p>
|
||||
<a href={activationLink} style={{ fontSize: 18, color: '#007bff' }}>
|
||||
Activate my account
|
||||
</a>
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>Once activated you will be able to:</p>
|
||||
<ul style={{ fontSize: 18 }}>
|
||||
<li>Access your dashboard</li>
|
||||
<li>Register and enroll your child(ren) in classes</li>
|
||||
<li>Receive school notifications and updates</li>
|
||||
</ul>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
Contact us at{' '}
|
||||
<a href={`mailto:${contactInfo}`} style={{ color: '#0000EE' }}>
|
||||
{contactInfo}
|
||||
</a>{' '}
|
||||
if you have any questions.
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
Warm regards,
|
||||
<br />
|
||||
<strong>{orgName}</strong>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WelcomeStaffBody({
|
||||
recipientName = 'Teacher',
|
||||
orgName = 'Al Rahma Sunday School',
|
||||
activationLink = `${PORTAL_HREF}/login`,
|
||||
contactInfo = 'alrahma.isgl@gmail.com',
|
||||
}: {
|
||||
recipientName?: string
|
||||
orgName?: string
|
||||
activationLink?: string
|
||||
contactInfo?: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 18 }}>Dear {recipientName},</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
Thank you for creating an account with us — we're thrilled to welcome you to <strong>{orgName}</strong>{' '}
|
||||
community!
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>To activate your account, click the link below:</p>
|
||||
<p>
|
||||
<a href={activationLink} style={{ fontSize: 18, color: '#007bff' }}>
|
||||
Activate my account
|
||||
</a>
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>Once activated you will be able to:</p>
|
||||
<ul style={{ fontSize: 18 }}>
|
||||
<li>Access your dashboard</li>
|
||||
<li>Receive school notifications and updates</li>
|
||||
</ul>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
Contact us at{' '}
|
||||
<a href={`mailto:${contactInfo}`} style={{ color: '#0000EE' }}>
|
||||
{contactInfo}
|
||||
</a>{' '}
|
||||
if you have any questions.
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
Warm regards,
|
||||
<br />
|
||||
<strong>{orgName}</strong>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WelcomeUserBody({
|
||||
user = { firstname: 'Friend' },
|
||||
}: {
|
||||
user?: { firstname?: string }
|
||||
}) {
|
||||
const first = user.firstname ?? 'User'
|
||||
return (
|
||||
<>
|
||||
<h2>Welcome, {first.charAt(0).toUpperCase() + first.slice(1)}!</h2>
|
||||
<p>
|
||||
Thank you for registering with <strong>Al Rahma Sunday School</strong>! We're excited to have you as part of
|
||||
our growing community.
|
||||
</p>
|
||||
<p>
|
||||
At Al Rahma, we're committed to nurturing spiritual growth, knowledge and strong Islamic values in a welcoming
|
||||
and engaging environment. Whether you're a new student, a parent or a community member, we're thrilled to
|
||||
begin this journey with you.
|
||||
</p>
|
||||
<p>Here's what you can expect as a new member:</p>
|
||||
<ul>
|
||||
<li>📚 Access to enriching Islamic studies and Quranic education</li>
|
||||
<li>🤝 A warm and supportive learning environment</li>
|
||||
<li>🕌 Regular events, announcements and community updates</li>
|
||||
</ul>
|
||||
<p style={{ fontSize: 16 }}>
|
||||
Click{' '}
|
||||
<a href={`${PORTAL_HREF}/login`} target="_blank" rel="noopener noreferrer">
|
||||
here
|
||||
</a>{' '}
|
||||
to login to your account securely to access your parent portal. There you can register and enroll your child(ren)
|
||||
in classes, check their enrollment status and track their academic progress, view/print your invoice, and check
|
||||
school events and calendar.
|
||||
</p>
|
||||
<p>
|
||||
Need help or have questions? Don't hesitate to{' '}
|
||||
<a href="mailto:alrahma.isgl@gmail.com">contact us</a>. We're here to support you every step of the way.
|
||||
</p>
|
||||
<p>
|
||||
Once again, welcome to the Al Rahma Sunday School family. We look forward to a wonderful and meaningful experience
|
||||
together!
|
||||
</p>
|
||||
<p>
|
||||
Warm regards,
|
||||
<br />
|
||||
<strong>Al Rahma Sunday School Team</strong>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResetPasswordBody({
|
||||
resetLink = `${PORTAL_HREF}/forgot-password`,
|
||||
orgName = 'Al Rahma Sunday School',
|
||||
}: {
|
||||
resetLink?: string
|
||||
orgName?: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 18 }}>Hello,</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
We received a request to reset your password. If you initiated this request, please click on the link below to set
|
||||
a new password:
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
<a href={resetLink} style={{ color: '#007bff' }}>
|
||||
Reset Password
|
||||
</a>
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>If you did not request a password reset, please discard this email.</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
Warm regards,
|
||||
<br />
|
||||
<strong>{orgName}</strong>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudentRemovedBody({
|
||||
parent_name = 'Parent/Guardian',
|
||||
student_name = 'Sara Ali',
|
||||
signature = 'AlRahma School Administration',
|
||||
}: {
|
||||
parent_name?: string
|
||||
student_name?: string
|
||||
signature?: string
|
||||
}) {
|
||||
return (
|
||||
<div style={{ fontFamily: 'Arial, Helvetica, sans-serif', color: '#222', fontSize: 16, lineHeight: 1.6 }}>
|
||||
<p style={{ margin: '0 0 12px' }}>Dear {parent_name},</p>
|
||||
<p style={{ margin: '0 0 12px' }}>
|
||||
We are writing to inform you that{' '}
|
||||
{student_name ? (
|
||||
<>
|
||||
your child, <strong>{student_name}</strong>,
|
||||
</>
|
||||
) : (
|
||||
'your child'
|
||||
)}{' '}
|
||||
has been officially withdrawn from the school's enrollment list, effective immediately.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px' }}>
|
||||
To ensure confidentiality and accuracy, we kindly request that you contact the school directly. This will allow us
|
||||
to provide complete information and discuss any necessary next steps.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px' }}>Thank you for your understanding. We look forward to speaking with you soon.</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
Sincerely,
|
||||
<br />
|
||||
{signature}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SupportNewAccountBody({
|
||||
user = { id: '42', firstname: 'Jamie', lastname: 'Rivera', email: 'new.user@example.com' },
|
||||
}: {
|
||||
user?: { id?: string | number; firstname?: string; lastname?: string; email?: string }
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h2>📬 New Account Created</h2>
|
||||
<p>
|
||||
<strong>Name:</strong> {user.firstname} {user.lastname}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Email:</strong> {user.email ?? 'N/A'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>User ID:</strong> {user.id ?? 'N/A'}
|
||||
</p>
|
||||
<p>This user has just completed account setup. Please verify and take any necessary actions.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminStudentRegisteredBody({
|
||||
student = {
|
||||
firstname: 'Sara',
|
||||
lastname: 'Ali',
|
||||
parents: { firstname: 'Parent', lastname: 'One', email: 'parent@example.com' },
|
||||
},
|
||||
}: {
|
||||
student?: {
|
||||
firstname?: string
|
||||
lastname?: string
|
||||
parents?: { firstname?: string; lastname?: string; email?: string }
|
||||
}
|
||||
}) {
|
||||
const p = student.parents
|
||||
return (
|
||||
<>
|
||||
<h2>🎓 New Student Registered</h2>
|
||||
<p>
|
||||
<strong>Student Name:</strong> {student.firstname} {student.lastname}
|
||||
</p>
|
||||
{p ? (
|
||||
<>
|
||||
<h3>👪 Parent Info</h3>
|
||||
<p>
|
||||
<strong>Parent:</strong> {p.firstname} {p.lastname}
|
||||
<br />
|
||||
<strong>Email:</strong> {p.email ?? 'N/A'}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
<p>The student has just been registered. Please review their information and verify if needed.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CalendarNotificationBody({
|
||||
event = {
|
||||
title: 'Community Iftar',
|
||||
date: '2026-05-01',
|
||||
event_type: 'Community',
|
||||
description: 'Join us after Maghrib for a community gathering.',
|
||||
},
|
||||
calendarUrl = `${PORTAL_HREF}/calendar`,
|
||||
}: {
|
||||
event?: { title?: string; date?: string; event_type?: string; description?: string }
|
||||
calendarUrl?: string
|
||||
}) {
|
||||
const eventTitle = event.title?.trim() || 'School Calendar Update'
|
||||
const eventType = event.event_type?.trim() || 'General'
|
||||
const raw = event.date?.trim()
|
||||
const eventDate = raw
|
||||
? new Date(`${raw}T12:00:00`).toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
: 'TBD'
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', lineHeight: 1.6 }}>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>{eventTitle}</h1>
|
||||
<p style={{ margin: '0.25rem 0' }}>
|
||||
<strong>Date:</strong> {eventDate}
|
||||
</p>
|
||||
<p style={{ margin: '0.25rem 0' }}>
|
||||
<strong>Type:</strong> {eventType}
|
||||
</p>
|
||||
{event.description ? (
|
||||
<p style={{ margin: '0.25rem 0', whiteSpace: 'pre-wrap' }}>{event.description}</p>
|
||||
) : null}
|
||||
<p style={{ margin: '0.25rem 0' }}>
|
||||
Visit the{' '}
|
||||
<a href={calendarUrl}>{calendarUrl}</a> to see the calendar.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EventBroadcastBody({
|
||||
event = {
|
||||
event_name: 'Science Fair',
|
||||
expiration_date: '2026-06-15',
|
||||
event_category: 'Academic',
|
||||
amount: 15,
|
||||
description: '<p>Bring your poster boards.</p>',
|
||||
},
|
||||
flyerUrl = '',
|
||||
eventUrl = `${PORTAL_HREF}/parent/events`,
|
||||
}: {
|
||||
event?: {
|
||||
event_name?: string
|
||||
expiration_date?: string
|
||||
event_category?: string
|
||||
amount?: number | string | null
|
||||
description?: string
|
||||
}
|
||||
flyerUrl?: string
|
||||
eventUrl?: string
|
||||
}) {
|
||||
const eventName = (event.event_name ?? 'Upcoming Event').trim()
|
||||
const eventCategory = (event.event_category ?? '').trim()
|
||||
const raw = (event.expiration_date ?? '').trim()
|
||||
const eventDate = raw
|
||||
? new Date(`${raw}T12:00:00`).toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
: 'TBD'
|
||||
const amountRaw = event.amount
|
||||
const amount =
|
||||
amountRaw !== null && amountRaw !== undefined && amountRaw !== ''
|
||||
? Number(amountRaw).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: null
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', lineHeight: 1.6 }}>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>{eventName}</h1>
|
||||
<p style={{ margin: '0.25rem 0' }}>
|
||||
<strong>Date:</strong> {eventDate}
|
||||
</p>
|
||||
{eventCategory ? (
|
||||
<p style={{ margin: '0.25rem 0' }}>
|
||||
<strong>Category:</strong> {eventCategory}
|
||||
</p>
|
||||
) : null}
|
||||
{amount !== null ? (
|
||||
<p style={{ margin: '0.25rem 0' }}>
|
||||
<strong>Amount:</strong> ${amount}
|
||||
</p>
|
||||
) : null}
|
||||
{event.description ? (
|
||||
<div style={{ margin: '0.75rem 0' }} dangerouslySetInnerHTML={{ __html: event.description }} />
|
||||
) : null}
|
||||
{flyerUrl ? (
|
||||
<>
|
||||
<div style={{ margin: '1rem 0' }}>
|
||||
<img
|
||||
src={flyerUrl}
|
||||
alt={`Event flyer for ${eventName}`}
|
||||
style={{ display: 'block', maxWidth: '100%', height: 'auto', border: '1px solid #ddd', borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ margin: '0.75rem 0' }}>
|
||||
Flyer:{' '}
|
||||
<a href={flyerUrl}>{flyerUrl}</a>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
<p style={{ margin: '0.75rem 0' }}>
|
||||
For more details, please contact the school office or review the parent event page.
|
||||
</p>
|
||||
<p style={{ margin: '0.25rem 0' }}>
|
||||
<a href={eventUrl}>{eventUrl}</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomHtmlBody({
|
||||
body_html = '<p>Custom <strong>HTML</strong> from the sender.</p>',
|
||||
}: {
|
||||
body_html?: string
|
||||
}) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: body_html }} />
|
||||
}
|
||||
|
||||
/** `_wrap_layout.php`: inner HTML passed into email_layout */
|
||||
export function WrapLayoutSampleBody({
|
||||
body_html = '<p style="font-size:16px;">Inner HTML injected via <code>$body_html</code> (CI <code>_wrap_layout.php</code>).</p>',
|
||||
}: {
|
||||
body_html?: string
|
||||
}) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: body_html }} />
|
||||
}
|
||||
|
||||
/** `broadcast_wrapper.php`: content slot */
|
||||
export function BroadcastWrapperSampleBody({
|
||||
content = '<p>This area maps to <code>$content</code> in <code>broadcast_wrapper.php</code>.</p>',
|
||||
}: {
|
||||
content?: string
|
||||
}) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { formatSectionName, joinWithAnd } from '../whatsappInviteUtils'
|
||||
|
||||
type WaItem = {
|
||||
class_section_name?: string
|
||||
invite_link?: string
|
||||
qr_src?: string
|
||||
}
|
||||
|
||||
export function WhatsAppInviteBody({
|
||||
parent = { firstname: 'Fatima', lastname: 'Hassan' },
|
||||
items = [
|
||||
{
|
||||
class_section_name: '5-A',
|
||||
invite_link: 'https://chat.whatsapp.com/example',
|
||||
qr_src: '',
|
||||
},
|
||||
] as WaItem[],
|
||||
sections,
|
||||
links,
|
||||
qrImageUrl,
|
||||
}: {
|
||||
parent?: { firstname?: string; lastname?: string }
|
||||
items?: WaItem[]
|
||||
sections?: WaItem[]
|
||||
links?: string[]
|
||||
qrImageUrl?: string
|
||||
}) {
|
||||
const displayName = `${parent.firstname ?? ''} ${parent.lastname ?? ''}`.trim()
|
||||
const sectionNames = (sections ?? []).map((s) => s.class_section_name ?? '')
|
||||
const linkFallback = links?.[0] ?? sections?.[0]?.invite_link ?? '#'
|
||||
const introSections =
|
||||
items.length > 0 ? items.map((i) => i.class_section_name ?? 'Class') : sectionNames
|
||||
|
||||
const formattedSections = introSections.map((s) => formatSectionName(String(s)))
|
||||
const sectionList = joinWithAnd(formattedSections) || 'your class'
|
||||
|
||||
const useItems = items.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 8,
|
||||
padding: 30,
|
||||
maxWidth: 720,
|
||||
margin: 'auto',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
fontFamily: "'Segoe UI', Arial, sans-serif",
|
||||
color: '#333',
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 18 }}>Assalamu Alaikum {displayName},</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
We are excited to welcome you to this school year at <strong>Al Rahma Sunday School</strong>. As we approach our
|
||||
fourth week of learning, we ask Allah (SWT) to make this year successful and beneficial for all our children.
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
You have been identified as a parent or guardian of at least one child in <strong>{sectionList}</strong> this year.
|
||||
We are inviting you to join the WhatsApp group(s) that include other parents and your child's instructors. The
|
||||
WhatsApp group(s) will be used to share important information about curriculum assignments and school events.
|
||||
</p>
|
||||
<p style={{ fontSize: 18 }}>
|
||||
Additionally, by joining these groups, you automatically become part of the{' '}
|
||||
<strong>Al Rahma Sunday School Community</strong>, which hosts all grade groups within the school. Through this
|
||||
community, you will receive important alerts and messages from the school administration.
|
||||
</p>
|
||||
<h2 style={{ fontSize: 22, color: '#1c5f2c', fontWeight: 600, marginTop: 22 }}>
|
||||
Your WhatsApp Group(s){items.length ? ` (${items.length})` : ''}
|
||||
</h2>
|
||||
|
||||
{useItems ? (
|
||||
items.map((it, i) => {
|
||||
const sectionLabel = formatSectionName(it.class_section_name ?? 'Class')
|
||||
const inviteLink = it.invite_link ?? '#'
|
||||
const qrSrc = it.qr_src ?? ''
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
border: '1px solid #e8e8e8',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
margin: '16px 0',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, marginBottom: 10, fontSize: 22 }}>
|
||||
{i + 1}) {sectionLabel}
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: 10, marginBottom: 20 }}>
|
||||
<a
|
||||
href={inviteLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#25D366',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: 8,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
Join WhatsApp Group
|
||||
</a>
|
||||
<div style={{ marginTop: 15 }}>
|
||||
{qrSrc ? (
|
||||
<img src={qrSrc} alt={`QR for ${sectionLabel}`} width={260} height={260} style={{ display: 'block', margin: 'auto' }} />
|
||||
) : (
|
||||
<div style={{ color: '#777', fontSize: 20 }}>
|
||||
<em>QR unavailable</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div style={{ border: '1px solid #e8e8e8', borderRadius: 10, padding: 16, margin: '16px 0', background: '#fff' }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 10, fontSize: 22 }}>
|
||||
{joinWithAnd(sectionNames.map((s) => formatSectionName(s))) || 'Class'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ flex: '1 1 280px', minWidth: 260 }}>
|
||||
<a
|
||||
href={linkFallback}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#25D366',
|
||||
color: '#fff',
|
||||
padding: '14px 24px',
|
||||
borderRadius: 8,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
Join WhatsApp Group
|
||||
</a>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<a href={linkFallback} target="_blank" rel="noopener noreferrer" style={{ fontSize: 18, color: '#0b6ef6', wordBreak: 'break-all' }}>
|
||||
{linkFallback}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: '0 0 260px', textAlign: 'center' }}>
|
||||
{qrImageUrl ? (
|
||||
<img src={qrImageUrl} alt="WhatsApp QR Code" width={260} height={260} style={{ display: 'block', margin: 'auto' }} />
|
||||
) : (
|
||||
<div style={{ color: '#777', fontSize: 20 }}>
|
||||
<em>QR unavailable</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: 22, fontSize: 18, color: '#555', textAlign: 'center' }}>
|
||||
Jazakum Allah Khair,
|
||||
<br />
|
||||
<strong>Al Rahma Sunday School Administration</strong>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** CI file is plain-text draft — rendered as fixed message */
|
||||
export function WhatsAppGroupInvitationDraftBody() {
|
||||
return (
|
||||
<div style={{ fontFamily: 'Arial, sans-serif', fontSize: 16, lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
|
||||
{`titile : Invitation to Join Youth WhatsApp Group
|
||||
|
||||
|
||||
Assalam Alaikum Parents and Guardians,
|
||||
|
||||
We are excited to welcome you to this school year at Al Rahma Sunday School.
|
||||
|
||||
We are heading towards the 4th week of learning this coming Sunday and we ask Allah swt to make this year successful and beneficial for our children.
|
||||
|
||||
You have been identified as a parent/guardian to at least one child in Youth this year, and we are inviting you to join the WhatsApp group that will be hosting all other parents who have their children in the same grade along with their respective instructors Br Sheraz, Br Umar Farooq, Br Hassan, Br Nabil, Sr Mariama and Sr Zainab. The WhatsApp group will be used to communicate important matters about curriculum, assignments and events.
|
||||
|
||||
Also, by joining Youth, you are automatically joining Al Rahma Sunday School Community which hosts all grade groups within the school, you will be able to receive important alerts and messages from school administration through the WhatsApp community as well.
|
||||
|
||||
Two ways to join:
|
||||
|
||||
1- If you are seeing this email in your desktop/laptop, then use your smart phone to scan the QR code below:
|
||||
|
||||
image.png
|
||||
|
||||
|
||||
2- If you are seeing this email in your smart phone, then simply click on this link below to join:
|
||||
|
||||
Youth WhatsApp Invitation Link
|
||||
|
||||
Jazakum Allah Khair,
|
||||
-Al Rahma Admin`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { JSX } from 'react'
|
||||
import {
|
||||
BelowSixtyPerformanceBody,
|
||||
DismissalBody,
|
||||
FinalWarningBody,
|
||||
FollowUpBody,
|
||||
ParentAttendanceAdminBody,
|
||||
ParentAttendanceParentBody,
|
||||
} from './bodies/AttendanceEmailBodies'
|
||||
import {
|
||||
ExtraChargeNoticeBody,
|
||||
PaymentReceiptBody,
|
||||
StatusAdmissionReviewBody,
|
||||
StatusDeniedBody,
|
||||
StatusEnrolledBody,
|
||||
StatusNotEnrolledBody,
|
||||
StatusPaymentPendingBody,
|
||||
StatusRefundPendingBody,
|
||||
StatusWaitlistBody,
|
||||
StatusWithdrawReviewBody,
|
||||
StatusWithdrawnBody,
|
||||
} from './bodies/EnrollmentEmailBodies'
|
||||
import {
|
||||
AdminStudentRegisteredBody,
|
||||
BroadcastWrapperSampleBody,
|
||||
CalendarNotificationBody,
|
||||
CustomHtmlBody,
|
||||
EventBroadcastBody,
|
||||
ResetPasswordBody,
|
||||
StudentRemovedBody,
|
||||
SupportNewAccountBody,
|
||||
WelcomeParentBody,
|
||||
WelcomeStaffBody,
|
||||
WelcomeUserBody,
|
||||
WrapLayoutSampleBody,
|
||||
} from './bodies/MiscEmailBodies'
|
||||
import { WhatsAppGroupInvitationDraftBody, WhatsAppInviteBody } from './bodies/WhatsAppEmailBodies'
|
||||
|
||||
export type EmailTemplateSlug =
|
||||
| 'final-warning'
|
||||
| 'follow-up'
|
||||
| 'parent-attendance-admin'
|
||||
| 'parent-attendance-parent'
|
||||
| 'below-sixty-performance'
|
||||
| 'dismissal'
|
||||
| 'payment-receipt'
|
||||
| 'extra-charge-notice'
|
||||
| 'reset-password'
|
||||
| 'welcome-parent'
|
||||
| 'welcome-staff'
|
||||
| 'welcome-user'
|
||||
| 'student-removed'
|
||||
| 'support-new-account'
|
||||
| 'admin-student-registered'
|
||||
| 'status-admission-review'
|
||||
| 'status-denied'
|
||||
| 'status-enrolled'
|
||||
| 'status-not-enrolled'
|
||||
| 'status-payment-pending'
|
||||
| 'status-refund-pending'
|
||||
| 'status-waitlist'
|
||||
| 'status-withdraw-review'
|
||||
| 'status-withdrawn'
|
||||
| 'calendar-notification'
|
||||
| 'event-broadcast'
|
||||
| 'custom-html'
|
||||
| 'wrap-layout-sample'
|
||||
| 'broadcast-wrapper-sample'
|
||||
| 'whatsapp-invite'
|
||||
| 'whatsapp-group-invitation'
|
||||
|
||||
export type RegistryEntry = {
|
||||
title: string
|
||||
ciView: string
|
||||
render: () => JSX.Element
|
||||
}
|
||||
|
||||
export const EMAIL_TEMPLATE_REGISTRY: Record<EmailTemplateSlug, RegistryEntry> = {
|
||||
'final-warning': {
|
||||
title: 'Final attendance warning',
|
||||
ciView: 'app/Views/emails/final_warning.php',
|
||||
render: () => <FinalWarningBody />,
|
||||
},
|
||||
'follow-up': {
|
||||
title: 'Attendance follow-up',
|
||||
ciView: 'app/Views/emails/follow_up.php',
|
||||
render: () => <FollowUpBody />,
|
||||
},
|
||||
dismissal: {
|
||||
title: 'Dismissal (absences)',
|
||||
ciView: 'app/Views/emails/dismissal.php',
|
||||
render: () => <DismissalBody />,
|
||||
},
|
||||
'parent-attendance-admin': {
|
||||
title: 'Parent attendance — admin notification',
|
||||
ciView: 'app/Views/emails/parent_attendance_admin.php',
|
||||
render: () => <ParentAttendanceAdminBody />,
|
||||
},
|
||||
'parent-attendance-parent': {
|
||||
title: 'Parent attendance — confirmation to parent',
|
||||
ciView: 'app/Views/emails/parent_attendance_parent.php',
|
||||
render: () => <ParentAttendanceParentBody />,
|
||||
},
|
||||
'below-sixty-performance': {
|
||||
title: 'Below 60 performance',
|
||||
ciView: 'app/Views/emails/below_sixty_performance.php',
|
||||
render: () => <BelowSixtyPerformanceBody />,
|
||||
},
|
||||
'payment-receipt': {
|
||||
title: 'Payment receipt',
|
||||
ciView: 'app/Views/emails/payment_receipt.php',
|
||||
render: () => <PaymentReceiptBody />,
|
||||
},
|
||||
'extra-charge-notice': {
|
||||
title: 'Extra charge notice',
|
||||
ciView: 'app/Views/emails/extra_charge_notice.php',
|
||||
render: () => <ExtraChargeNoticeBody />,
|
||||
},
|
||||
'reset-password': {
|
||||
title: 'Reset password',
|
||||
ciView: 'app/Views/emails/reset_password.php',
|
||||
render: () => <ResetPasswordBody />,
|
||||
},
|
||||
'welcome-parent': {
|
||||
title: 'Welcome — parent activation',
|
||||
ciView: 'app/Views/emails/welcome_parent.php',
|
||||
render: () => <WelcomeParentBody />,
|
||||
},
|
||||
'welcome-staff': {
|
||||
title: 'Welcome — staff activation',
|
||||
ciView: 'app/Views/emails/welcome_staff.php',
|
||||
render: () => <WelcomeStaffBody />,
|
||||
},
|
||||
'welcome-user': {
|
||||
title: 'Welcome — generic user',
|
||||
ciView: 'app/Views/emails/welcome_user.php',
|
||||
render: () => <WelcomeUserBody />,
|
||||
},
|
||||
'student-removed': {
|
||||
title: 'Student removed',
|
||||
ciView: 'app/Views/emails/student_removed.php',
|
||||
render: () => <StudentRemovedBody />,
|
||||
},
|
||||
'support-new-account': {
|
||||
title: 'Support — new account alert',
|
||||
ciView: 'app/Views/emails/support_new_account.php',
|
||||
render: () => <SupportNewAccountBody />,
|
||||
},
|
||||
'admin-student-registered': {
|
||||
title: 'Admin — student registered',
|
||||
ciView: 'app/Views/emails/admin_student_registered.php',
|
||||
render: () => <AdminStudentRegisteredBody />,
|
||||
},
|
||||
'status-admission-review': {
|
||||
title: 'Status — admission under review',
|
||||
ciView: 'app/Views/emails/status_admission_review.php',
|
||||
render: () => <StatusAdmissionReviewBody />,
|
||||
},
|
||||
'status-denied': {
|
||||
title: 'Status — admission denied',
|
||||
ciView: 'app/Views/emails/status_denied.php',
|
||||
render: () => <StatusDeniedBody />,
|
||||
},
|
||||
'status-enrolled': {
|
||||
title: 'Status — enrolled',
|
||||
ciView: 'app/Views/emails/status_enrolled.php',
|
||||
render: () => <StatusEnrolledBody />,
|
||||
},
|
||||
'status-not-enrolled': {
|
||||
title: 'Status — not enrolled',
|
||||
ciView: 'app/Views/emails/status_not_enrolled.php',
|
||||
render: () => <StatusNotEnrolledBody />,
|
||||
},
|
||||
'status-payment-pending': {
|
||||
title: 'Status — payment pending',
|
||||
ciView: 'app/Views/emails/status_payment_pending.php',
|
||||
render: () => <StatusPaymentPendingBody />,
|
||||
},
|
||||
'status-refund-pending': {
|
||||
title: 'Status — refund pending',
|
||||
ciView: 'app/Views/emails/status_refund_pending.php',
|
||||
render: () => <StatusRefundPendingBody />,
|
||||
},
|
||||
'status-waitlist': {
|
||||
title: 'Status — waitlist',
|
||||
ciView: 'app/Views/emails/status_waitlist.php',
|
||||
render: () => <StatusWaitlistBody />,
|
||||
},
|
||||
'status-withdraw-review': {
|
||||
title: 'Status — withdraw under review',
|
||||
ciView: 'app/Views/emails/status_withdraw_review.php',
|
||||
render: () => <StatusWithdrawReviewBody />,
|
||||
},
|
||||
'status-withdrawn': {
|
||||
title: 'Status — withdrawn',
|
||||
ciView: 'app/Views/emails/status_withdrawn.php',
|
||||
render: () => <StatusWithdrawnBody />,
|
||||
},
|
||||
'calendar-notification': {
|
||||
title: 'Calendar notification',
|
||||
ciView: 'app/Views/emails/calendar_notification.php',
|
||||
render: () => <CalendarNotificationBody />,
|
||||
},
|
||||
'event-broadcast': {
|
||||
title: 'Event broadcast',
|
||||
ciView: 'app/Views/emails/event_broadcast.php',
|
||||
render: () => <EventBroadcastBody />,
|
||||
},
|
||||
'custom-html': {
|
||||
title: 'Custom HTML body',
|
||||
ciView: 'app/Views/emails/custom_html.php',
|
||||
render: () => <CustomHtmlBody />,
|
||||
},
|
||||
'wrap-layout-sample': {
|
||||
title: 'Wrap layout ($body_html)',
|
||||
ciView: 'app/Views/emails/_wrap_layout.php',
|
||||
render: () => <WrapLayoutSampleBody />,
|
||||
},
|
||||
'broadcast-wrapper-sample': {
|
||||
title: 'Broadcast wrapper ($content)',
|
||||
ciView: 'app/Views/emails/broadcast_wrapper.php',
|
||||
render: () => <BroadcastWrapperSampleBody />,
|
||||
},
|
||||
'whatsapp-invite': {
|
||||
title: 'WhatsApp invite',
|
||||
ciView: 'app/Views/emails/whatsapp_invite.php',
|
||||
render: () => <WhatsAppInviteBody />,
|
||||
},
|
||||
'whatsapp-group-invitation': {
|
||||
title: 'WhatsApp group invitation (draft text)',
|
||||
ciView: 'app/Views/emails/whatsapp_group_invitation.php',
|
||||
render: () => <WhatsAppGroupInvitationDraftBody />,
|
||||
},
|
||||
}
|
||||
|
||||
export function isEmailTemplateSlug(s: string): s is EmailTemplateSlug {
|
||||
return s in EMAIL_TEMPLATE_REGISTRY
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { EmailPreviewLayout } from './EmailPreviewLayout'
|
||||
export { EmailTemplatesIndexPage } from './EmailTemplatesIndexPage'
|
||||
export { EmailTemplatePreviewPage } from './EmailTemplatePreviewPage'
|
||||
export { ParentEmailExtractorPage } from './ParentEmailExtractorPage'
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Normalize CI `$studentName` string | string[] into display lists */
|
||||
export function normalizeStudentNames(studentName: string | string[] | undefined): string[] {
|
||||
if (studentName == null) return []
|
||||
if (Array.isArray(studentName))
|
||||
return studentName.map((n) => String(n).trim()).filter(Boolean)
|
||||
const s = String(studentName).trim()
|
||||
if (!s) return []
|
||||
return s
|
||||
.split(/\s*,\s*|\s*,?\s+and\s+/i)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function joinNamesAnd(names: string[]): string {
|
||||
const n = names.length
|
||||
if (n === 0) return ''
|
||||
if (n === 1) return names[0]!
|
||||
if (n === 2) return `${names[0]} and ${names[1]}`
|
||||
return `${names.slice(0, -1).join(', ')}, and ${names[n - 1]}`
|
||||
}
|
||||
|
||||
export const PORTAL_HREF = 'https://alrahmaisgl.org'
|
||||
|
||||
export function formatLocalDate(d: string | undefined): string {
|
||||
if (!d) return '—'
|
||||
const t = Date.parse(d)
|
||||
if (Number.isNaN(t)) return d
|
||||
return new Date(t).toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Ports CI `whatsapp_invite.php` section-name helpers */
|
||||
|
||||
export function formatSectionName(section: string): string {
|
||||
const raw = section.trim()
|
||||
if (raw === '') return 'Class'
|
||||
|
||||
if (/^\s*grade\s+/i.test(raw)) {
|
||||
return 'Grade ' + raw.replace(/^\s*grade\s+/i, '').trim()
|
||||
}
|
||||
|
||||
const up = raw.toUpperCase()
|
||||
if (up === 'KG') return 'KG'
|
||||
if (up === 'YOUTH') return 'Youth'
|
||||
|
||||
const m = raw.match(/^\s*(\d{1,2})(?:\s*-\s*([A-Za-z]))?\s*$/)
|
||||
if (m) {
|
||||
const grade = Number(m[1])
|
||||
const sec = m[2] ? String(m[2]).toUpperCase() : ''
|
||||
return 'Grade ' + grade + (sec ? '-' + sec : '')
|
||||
}
|
||||
|
||||
return 'Grade ' + raw.charAt(0).toUpperCase() + raw.slice(1)
|
||||
}
|
||||
|
||||
export function joinWithAnd(items: Array<string | null | undefined>): string {
|
||||
const arr = items.filter((v): v is string => Boolean(v && String(v).trim()))
|
||||
const n = arr.length
|
||||
if (n === 0) return ''
|
||||
if (n === 1) return arr[0]!
|
||||
if (n === 2) return `${arr[0]} and ${arr[1]}`
|
||||
return `${arr.slice(0, -1).join(', ')} and ${arr[n - 1]}`
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
fetchEnrollmentWithdrawalPage,
|
||||
postEnrollmentWithdrawalAssignClass,
|
||||
postEnrollmentWithdrawalStatusBatch,
|
||||
postGenerateInvoiceForParent,
|
||||
} from '../../api/session'
|
||||
import type {
|
||||
EnrollmentWithdrawalClassOption,
|
||||
EnrollmentWithdrawalStudentRow,
|
||||
} from '../../api/types'
|
||||
import {
|
||||
EnrollmentStatusBadge,
|
||||
NewStudentBadge,
|
||||
normStatus,
|
||||
RemovedPriorYearBadge,
|
||||
} from './enrollmentStatusUi'
|
||||
import './enrollWithdrawPages.css'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'admission under review',
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
'withdrawn',
|
||||
'waitlist',
|
||||
'denied',
|
||||
] as const
|
||||
|
||||
const SHOULD_INVOICE = new Set([
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdrawn',
|
||||
'refund pending',
|
||||
'withdraw under review',
|
||||
])
|
||||
|
||||
function sidOf(row: EnrollmentWithdrawalStudentRow): number {
|
||||
return Number(row.student_id ?? row.id ?? 0)
|
||||
}
|
||||
|
||||
function pidOf(row: EnrollmentWithdrawalStudentRow): number {
|
||||
return Number(row.parent_id ?? row.guardian_id ?? 0)
|
||||
}
|
||||
|
||||
function formatRegistrationDate(raw?: string | null): string {
|
||||
if (raw == null || String(raw).trim() === '') return '-'
|
||||
const s = String(raw)
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (iso) return `${iso[2]}-${iso[3]}-${iso[1]}`
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? s : `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}-${d.getFullYear()}`
|
||||
}
|
||||
|
||||
function classOptionValue(c: EnrollmentWithdrawalClassOption): string {
|
||||
const v = c.class_section_id ?? c.id
|
||||
return v !== undefined && v !== null ? String(v) : ''
|
||||
}
|
||||
|
||||
function classOptionLabel(c: EnrollmentWithdrawalClassOption): string {
|
||||
const label = String(c.class_section_name ?? c.name ?? '').trim()
|
||||
const id = classOptionValue(c)
|
||||
return label || `Section ${id}`
|
||||
}
|
||||
|
||||
export function EnrollmentWithdrawalPage() {
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYearParam = searchParams.get('schoolYear') ?? ''
|
||||
const semesterParam = searchParams.get('semester') ?? ''
|
||||
|
||||
const [rows, setRows] = useState<EnrollmentWithdrawalStudentRow[]>([])
|
||||
const [classes, setClasses] = useState<EnrollmentWithdrawalClassOption[]>([])
|
||||
const [schoolYears, setSchoolYears] = useState<string[]>([])
|
||||
const [selectedYear, setSelectedYear] = useState('')
|
||||
const [currentYear, setCurrentYear] = useState('')
|
||||
const [missingYear, setMissingYear] = useState(false)
|
||||
const [isCurrentYear, setIsCurrentYear] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<{ ok: boolean; message: string } | null>(null)
|
||||
const [bulkWorking, setBulkWorking] = useState(false)
|
||||
|
||||
/** Pending edits: undefined means “use row value”. */
|
||||
const [pendingStatus, setPendingStatus] = useState<Record<number, string>>({})
|
||||
const [pendingClass, setPendingClass] = useState<Record<number, string>>({})
|
||||
|
||||
const readOnly = missingYear || !isCurrentYear
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchEnrollmentWithdrawalPage({
|
||||
schoolYear: schoolYearParam || undefined,
|
||||
semester: semesterParam || undefined,
|
||||
})
|
||||
.then((d) => {
|
||||
if (cancelled) return
|
||||
setRows(d.students)
|
||||
setClasses(d.classes)
|
||||
setSchoolYears(d.schoolYears.length > 0 ? d.schoolYears : [])
|
||||
setSelectedYear(d.selectedYear)
|
||||
setCurrentYear(d.currentYear)
|
||||
setMissingYear(d.missingYear)
|
||||
setIsCurrentYear(d.isCurrentYear)
|
||||
setPendingStatus({})
|
||||
setPendingClass({})
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load enrollment data.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYearParam, semesterParam])
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return
|
||||
const t = window.setTimeout(() => setToast(null), 4000)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [toast])
|
||||
|
||||
const effectiveStatus = useMemo(() => {
|
||||
const out: Record<number, string> = {}
|
||||
for (const row of rows) {
|
||||
const sid = sidOf(row)
|
||||
if (!sid) continue
|
||||
const base = String(row.enrollment_status ?? '').trim()
|
||||
out[sid] = pendingStatus[sid] ?? base
|
||||
}
|
||||
return out
|
||||
}, [rows, pendingStatus])
|
||||
|
||||
function onFilterSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const sy = String(fd.get('schoolYear') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
const next = new URLSearchParams()
|
||||
if (sy) next.set('schoolYear', sy)
|
||||
if (sem) next.set('semester', sem)
|
||||
navigate({ pathname, search: next.toString() ? `?${next}` : '' })
|
||||
}
|
||||
|
||||
async function onBulkSaveGenerate() {
|
||||
if (readOnly) return
|
||||
setBulkWorking(true)
|
||||
const errors: string[] = []
|
||||
const batchUpdates: { student_id: number; enrollment_status: string }[] = []
|
||||
const parentInvoice = new Set<number>()
|
||||
|
||||
type Task = {
|
||||
studentId: number
|
||||
parentId: number
|
||||
classId: string
|
||||
}
|
||||
type StatusTask = { studentId: number; parentId: number; desired: string }
|
||||
|
||||
const tasks: Task[] = []
|
||||
const statusTasks: StatusTask[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const studentId = sidOf(row)
|
||||
const parentId = pidOf(row)
|
||||
if (!studentId) continue
|
||||
|
||||
const prev = normStatus(row.enrollment_status ?? '')
|
||||
const desiredRaw = (pendingStatus[studentId] ?? String(row.enrollment_status ?? '')).trim()
|
||||
const desired = normStatus(desiredRaw)
|
||||
const classId = pendingClass[studentId] ?? ''
|
||||
|
||||
const aur = normStatus('admission under review')
|
||||
const pp = normStatus('payment pending')
|
||||
const isAurToPp = prev === aur && desired === pp
|
||||
|
||||
if (isAurToPp && classId) {
|
||||
tasks.push({ studentId, parentId, classId })
|
||||
continue
|
||||
}
|
||||
|
||||
if (desired && desired !== prev) {
|
||||
if (isAurToPp && !classId) continue
|
||||
statusTasks.push({
|
||||
studentId,
|
||||
parentId,
|
||||
desired: desiredRaw.trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0 && statusTasks.length === 0) {
|
||||
setToast({
|
||||
ok: false,
|
||||
message:
|
||||
'Nothing to process. Set statuses as needed; for payment pending from admission under review, pick a class section.',
|
||||
})
|
||||
setBulkWorking(false)
|
||||
return
|
||||
}
|
||||
|
||||
for (const t of tasks) {
|
||||
try {
|
||||
if (!t.parentId) throw new Error('Missing parent for student.')
|
||||
await postEnrollmentWithdrawalAssignClass({
|
||||
student_id: t.studentId,
|
||||
parent_id: t.parentId,
|
||||
class_section_id: t.classId,
|
||||
})
|
||||
batchUpdates.push({ student_id: t.studentId, enrollment_status: 'payment pending' })
|
||||
parentInvoice.add(t.parentId)
|
||||
} catch (e: unknown) {
|
||||
errors.push(
|
||||
`Assign class (student ${t.studentId}): ${e instanceof ApiHttpError ? e.message : String(e)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of statusTasks) {
|
||||
batchUpdates.push({ student_id: s.studentId, enrollment_status: s.desired })
|
||||
const n = normStatus(s.desired)
|
||||
if (s.parentId && SHOULD_INVOICE.has(n)) parentInvoice.add(s.parentId)
|
||||
}
|
||||
|
||||
const mergedByStudent = new Map<number, string>()
|
||||
for (const u of batchUpdates) mergedByStudent.set(u.student_id, u.enrollment_status)
|
||||
const deduped = [...mergedByStudent.entries()].map(([student_id, enrollment_status]) => ({
|
||||
student_id,
|
||||
enrollment_status,
|
||||
}))
|
||||
|
||||
try {
|
||||
if (deduped.length > 0) {
|
||||
await postEnrollmentWithdrawalStatusBatch({
|
||||
updates: deduped,
|
||||
school_year: selectedYear || undefined,
|
||||
semester: semesterParam || undefined,
|
||||
})
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
errors.push(e instanceof ApiHttpError ? e.message : 'Status update failed.')
|
||||
}
|
||||
|
||||
for (const pid of parentInvoice) {
|
||||
if (!pid) continue
|
||||
try {
|
||||
await postGenerateInvoiceForParent(pid)
|
||||
} catch {
|
||||
/* non-fatal, mirrors legacy */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshed = await fetchEnrollmentWithdrawalPage({
|
||||
schoolYear: schoolYearParam || undefined,
|
||||
semester: semesterParam || undefined,
|
||||
})
|
||||
setRows(refreshed.students)
|
||||
setClasses(refreshed.classes)
|
||||
setSchoolYears(refreshed.schoolYears.length > 0 ? refreshed.schoolYears : [])
|
||||
setSelectedYear(refreshed.selectedYear)
|
||||
setCurrentYear(refreshed.currentYear)
|
||||
setMissingYear(refreshed.missingYear)
|
||||
setIsCurrentYear(refreshed.isCurrentYear)
|
||||
setPendingStatus({})
|
||||
setPendingClass({})
|
||||
} catch {
|
||||
/* ignore refresh failure */
|
||||
}
|
||||
|
||||
if (errors.length > 0) setToast({ ok: false, message: errors[0] ?? 'Some updates failed.' })
|
||||
else setToast({ ok: true, message: 'Saved and invoices queued.' })
|
||||
|
||||
setBulkWorking(false)
|
||||
}
|
||||
|
||||
const yearSelectValue =
|
||||
schoolYearParam || selectedYear || (schoolYears.length > 0 ? schoolYears[0] : '')
|
||||
const semesterSelectValue = semesterParam
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<div className="wrapper">
|
||||
<div className="content" />
|
||||
<h2 className="text-center mt-4 mb-3">Manage Enrollment & Withdrawal</h2>
|
||||
|
||||
{missingYear ? (
|
||||
<div className="alert alert-warning d-flex align-items-center" role="alert">
|
||||
<div>
|
||||
Current school year is not configured. This page is read-only until configured in{' '}
|
||||
<Link to="/app/administrator/configuration_view" className="alert-link">
|
||||
Add/Edit Configuration
|
||||
</Link>{' '}
|
||||
(key: <code>school_year</code>).
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{toast ? (
|
||||
<div className={`alert ${toast.ok ? 'alert-success' : 'alert-danger'}`} role="alert">
|
||||
{toast.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="row g-2 align-items-center justify-content-center mb-2">
|
||||
<div className="col-auto">
|
||||
<label htmlFor="schoolYearSelect" className="col-form-label">
|
||||
School year
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<form className="d-flex align-items-center gap-2 flex-wrap" onSubmit={onFilterSubmit}>
|
||||
<select
|
||||
id="schoolYearSelect"
|
||||
name="schoolYear"
|
||||
className="form-select form-select-sm"
|
||||
style={{ minWidth: 180 }}
|
||||
defaultValue={yearSelectValue}
|
||||
key={`${yearSelectValue}-${semesterSelectValue}`}
|
||||
>
|
||||
{schoolYears.length > 0 ? (
|
||||
schoolYears.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value={yearSelectValue}>{yearSelectValue || '—'}</option>
|
||||
)}
|
||||
</select>
|
||||
<label htmlFor="ewSemester" className="col-form-label">
|
||||
Semester
|
||||
</label>
|
||||
<select
|
||||
id="ewSemester"
|
||||
name="semester"
|
||||
className="form-select form-select-sm"
|
||||
style={{ minWidth: 140 }}
|
||||
defaultValue={semesterSelectValue}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="Fall">Fall</option>
|
||||
<option value="Spring">Spring</option>
|
||||
</select>
|
||||
<button type="submit" className="btn btn-secondary btn-sm">
|
||||
Apply
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{selectedYear && currentYear && selectedYear !== currentYear ? (
|
||||
<div className="col-auto">
|
||||
<span className="badge bg-secondary">Read-only (Past Year)</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? <p className="text-muted text-center">Loading…</p> : null}
|
||||
{!loading && error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-striped mt-4 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Registration Date</th>
|
||||
<th>Parent/Guardian</th>
|
||||
<th>Student Name</th>
|
||||
<th>New Student</th>
|
||||
<th>Removed (Prior Years)</th>
|
||||
<th>Current Class</th>
|
||||
<th>Actual Status</th>
|
||||
<th>Update Enrollment Status</th>
|
||||
<th>Assign Class</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && !error && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9}>No students available.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((student) => {
|
||||
const sid = sidOf(student)
|
||||
const pid = pidOf(student)
|
||||
const eff = normStatus(effectiveStatus[sid] ?? '')
|
||||
const base = String(student.enrollment_status ?? '').trim()
|
||||
const selectVal = pendingStatus[sid] ?? base
|
||||
const aur = normStatus('admission under review')
|
||||
const pending =
|
||||
normStatus(selectVal) !== normStatus(base) ||
|
||||
(eff === aur && (pendingClass[sid] ?? '') !== '')
|
||||
const showAssign = eff === aur
|
||||
const familyHref =
|
||||
sid > 0 ? `/app/administrator/family?student_id=${encodeURIComponent(String(sid))}` : '#'
|
||||
|
||||
return (
|
||||
<tr key={sid || `${student.firstname}-${student.lastname}`} data-student-id={sid} data-parent-id={pid}>
|
||||
<td>{formatRegistrationDate(student.registration_date)}</td>
|
||||
<td>{student.parent_label ?? 'Unknown Parent'}</td>
|
||||
<td>
|
||||
{sid ? (
|
||||
<Link to={familyHref} className="text-decoration-none">
|
||||
{student.firstname ?? ''} {student.lastname ?? ''}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{student.firstname ?? ''} {student.lastname ?? ''}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<NewStudentBadge value={student.new_student} />
|
||||
</td>
|
||||
<td>
|
||||
<RemovedPriorYearBadge value={student.removed_previous_year} />
|
||||
</td>
|
||||
<td>{student.class_section ?? 'Class not Assigned'}</td>
|
||||
<td>
|
||||
<EnrollmentStatusBadge status={student.enrollment_status ?? 'not enrolled'} />
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className={`form-control enrollment-status form-select form-select-sm ${pending ? 'pending-status-select' : ''}`}
|
||||
disabled={readOnly}
|
||||
value={selectVal}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setPendingStatus((p) => {
|
||||
const next = { ...p, [sid]: v }
|
||||
if (normStatus(v) === normStatus(student.enrollment_status ?? '')) {
|
||||
delete next[sid]
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (normStatus(v) !== aur) {
|
||||
setPendingClass((c) => {
|
||||
const next = { ...c }
|
||||
delete next[sid]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{showAssign ? (
|
||||
<select
|
||||
className="form-select form-select-sm assign-class-select"
|
||||
disabled={readOnly}
|
||||
value={pendingClass[sid] ?? ''}
|
||||
onChange={(e) =>
|
||||
setPendingClass((c) => ({ ...c, [sid]: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">— Select class section —</option>
|
||||
{classes.map((cls) => {
|
||||
const val = classOptionValue(cls)
|
||||
if (!val) return null
|
||||
return (
|
||||
<option key={val} value={val}>
|
||||
{classOptionLabel(cls)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-center gap-2 my-3 align-items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-success btn-lg"
|
||||
disabled={readOnly || bulkWorking}
|
||||
title={readOnly ? 'Editing disabled for non-current year or missing configuration.' : undefined}
|
||||
onClick={() => void onBulkSaveGenerate()}
|
||||
>
|
||||
Save & Generate Invoice
|
||||
</button>
|
||||
{bulkWorking ? <span className="small text-muted">Processing…</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchRegisteredNewStudents } from '../../api/session'
|
||||
import type { RegisteredNewStudentRow } from '../../api/types'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
EnrollmentStatusBadge,
|
||||
NewStudentBadge,
|
||||
} from './enrollmentStatusUi'
|
||||
|
||||
/** Admin new-students list — `GET /api/v1/administrator/enroll-withdrawal/registered-new-students` (JWT). SPA: `/app/admin/enrollment/new-students`. */
|
||||
|
||||
function formatRegistrationDate(raw?: string | null): string {
|
||||
if (raw == null || String(raw).trim() === '') return '-'
|
||||
const s = String(raw)
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (iso) return `${iso[2]}-${iso[3]}-${iso[1]}`
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? s : `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}-${d.getFullYear()}`
|
||||
}
|
||||
|
||||
export function RegisteredStudentsPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? undefined
|
||||
const semester = searchParams.get('semester') ?? undefined
|
||||
|
||||
const [rows, setRows] = useState<RegisteredNewStudentRow[]>([])
|
||||
const [schoolYears, setSchoolYears] = useState<string[] | undefined>(undefined)
|
||||
const [totalNew, setTotalNew] = useState<number | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [contactModal, setContactModal] = useState<RegisteredNewStudentRow | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchRegisteredNewStudents({ school_year: schoolYear, semester })
|
||||
.then((d) => {
|
||||
if (cancelled) return
|
||||
setRows(d.new_students ?? [])
|
||||
setTotalNew(d.total_new)
|
||||
setSchoolYears(d.school_years)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load registered students.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<div className="wrapper">
|
||||
<div className="content" />
|
||||
<h2 className="text-center mt-4 mb-3">
|
||||
Registered Students
|
||||
{totalNew !== undefined ? (
|
||||
<span className="badge bg-secondary ms-2">{totalNew}</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<AcademicFilterBar schoolYears={schoolYears} />
|
||||
|
||||
{loading ? <p className="text-muted text-center">Loading…</p> : null}
|
||||
{!loading && error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-striped mt-4 align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th>New Student</th>
|
||||
<th>Current Class</th>
|
||||
<th>Actual Status</th>
|
||||
<th>Age</th>
|
||||
<th>Registration Date</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && !error && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center">
|
||||
No students found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((student) => {
|
||||
const sid = Number(student.id ?? 0)
|
||||
const familyHref =
|
||||
sid > 0 ? `/app/administrator/family?student_id=${encodeURIComponent(String(sid))}` : '#'
|
||||
return (
|
||||
<tr key={sid || `${student.firstname}-${student.lastname}`}>
|
||||
<td>
|
||||
<Link to={familyHref} className="text-decoration-none">
|
||||
{student.firstname ?? ''}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Link to={familyHref} className="text-decoration-none">
|
||||
{student.lastname ?? ''}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<NewStudentBadge value={student.new_student} />
|
||||
</td>
|
||||
<td>{student.class_section ?? 'Class not Assigned'}</td>
|
||||
<td>
|
||||
<EnrollmentStatusBadge
|
||||
status={student.enrollment_status ?? 'not enrolled'}
|
||||
deniedVariant="light"
|
||||
/>
|
||||
</td>
|
||||
<td>{student.age !== undefined && student.age !== null ? String(student.age) : '-'}</td>
|
||||
<td>{formatRegistrationDate(student.registration_date)}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-warning btn-sm"
|
||||
onClick={() => setContactModal(student)}
|
||||
>
|
||||
Contact Information
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contactModal ? (
|
||||
<div
|
||||
className="modal fade show d-block"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.35)' }}
|
||||
onClick={() => setContactModal(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setContactModal(null)
|
||||
}}
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-scrollable" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-content shadow">
|
||||
<div className="modal-header bg-primary text-white">
|
||||
<h5 className="modal-title">
|
||||
Contact Info — {contactModal.firstname ?? ''} {contactModal.lastname ?? ''}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white"
|
||||
aria-label="Close"
|
||||
onClick={() => setContactModal(null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<h5 className="mb-3 border-bottom pb-1">Parent Information</h5>
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<p>
|
||||
<strong>Name:</strong>{' '}
|
||||
{`${String(contactModal.parent_firstname ?? '')} ${String(contactModal.parent_lastname ?? '')}`.trim()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Phone:</strong> {contactModal.parent_phone ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<p>
|
||||
<strong>Email:</strong> {contactModal.parent_email ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h5 className="mb-3 border-bottom pb-1">Emergency Contact</h5>
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<p>
|
||||
<strong>Name:</strong> {contactModal.emergency_name ?? 'N/A'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Relationship:</strong> {contactModal.emergency_relationship ?? 'N/A'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Phone:</strong> {contactModal.emergency_phone ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setContactModal(null)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.pending-status-select {
|
||||
border: 2px solid #ffc107 !important;
|
||||
box-shadow: 0 0 0 0.1rem rgba(255, 193, 7, 0.35);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Enrollment status badges aligned with legacy PHP enroll_withdraw views. */
|
||||
|
||||
export function normStatus(v: string | undefined | null): string {
|
||||
return String(v ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, ' ')
|
||||
}
|
||||
|
||||
type DeniedVariant = 'light' | 'dark'
|
||||
|
||||
export function EnrollmentStatusBadge({
|
||||
status,
|
||||
deniedVariant = 'dark',
|
||||
}: {
|
||||
status?: string | null
|
||||
deniedVariant?: DeniedVariant
|
||||
}) {
|
||||
const s = normStatus(status || 'not enrolled')
|
||||
|
||||
if (s === 'admission under review')
|
||||
return <span className="badge bg-primary">admission under review</span>
|
||||
if (s === 'payment pending')
|
||||
return <span className="badge bg-warning text-dark">payment pending</span>
|
||||
if (s === 'enrolled') return <span className="badge bg-success">enrolled</span>
|
||||
if (s === 'withdraw under review')
|
||||
return <span className="badge bg-warning text-dark">withdraw under review</span>
|
||||
if (s === 'refund pending')
|
||||
return <span className="badge bg-info text-dark">refund pending</span>
|
||||
if (s === 'withdrawn') return <span className="badge bg-danger">withdrawn</span>
|
||||
if (s === 'waitlist') return <span className="badge bg-secondary">waitlist</span>
|
||||
if (s === 'denied')
|
||||
return deniedVariant === 'light' ? (
|
||||
<span className="badge bg-light">denied</span>
|
||||
) : (
|
||||
<span className="badge bg-dark">denied</span>
|
||||
)
|
||||
if (s === 'not enrolled')
|
||||
return <span className="badge bg-danger text-dark">not enrolled</span>
|
||||
return <span className="badge bg-light text-dark">unknown</span>
|
||||
}
|
||||
|
||||
export function NewStudentBadge({ value }: { value?: string | null }) {
|
||||
const v = String(value ?? '').trim()
|
||||
if (v === 'Yes') return <span className="badge bg-warning text-dark">Yes</span>
|
||||
if (v === 'No') return <span className="badge bg-success">No</span>
|
||||
return <span className="badge bg-light text-dark">Unknown</span>
|
||||
}
|
||||
|
||||
export function RemovedPriorYearBadge({ value }: { value?: string | null }) {
|
||||
const v = String(value ?? '').trim()
|
||||
if (v === 'Yes') return <span className="badge bg-danger text-light">Yes</span>
|
||||
return <span className="badge bg-secondary">No</span>
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RegisteredStudentsPage } from './RegisteredStudentsPage'
|
||||
export { EnrollmentWithdrawalPage } from './EnrollmentWithdrawalPage'
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
/** Port of CodeIgniter `Views/errors/access_denied.php`. */
|
||||
export function AccessDeniedPage() {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div
|
||||
className="d-flex justify-content-center align-items-center flex-column text-center"
|
||||
style={{ minHeight: '80vh' }}
|
||||
>
|
||||
<h1 className="display-4 text-danger">Access Denied</h1>
|
||||
<p className="lead">You do not have permission to access this page.</p>
|
||||
<Link to="/" className="btn btn-primary mt-3">
|
||||
Go to Home Page
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
/** Port of CodeIgniter `Views/errors/invalid_token.php` (registration token invalid/expired). */
|
||||
export function InvalidTokenPage() {
|
||||
return (
|
||||
<div className="registration-form container mt-5 mb-5">
|
||||
<div
|
||||
className="bg-white p-5 rounded-5 shadow registration-form container"
|
||||
style={{ maxWidth: 600, width: '100%' }}
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/alrahma_logo.png"
|
||||
alt="Al Rahma Sunday School"
|
||||
style={{ width: 180, height: 120 }}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h3 className="text-center text-danger" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
Invalid or Expired Token
|
||||
</h3>
|
||||
<p className="text-center mb-1">The link you used is either:</p>
|
||||
<ul className="list-unstyled text-center mb-3">
|
||||
<li>❌ Invalid</li>
|
||||
<li>⏳ Expired</li>
|
||||
<li>✅ Already used</li>
|
||||
</ul>
|
||||
<p className="text-center text-secondary mb-4">Please try registering again.</p>
|
||||
<div className="mb-2 d-grid">
|
||||
<Link to="/register" className="btn btn-success item">
|
||||
Register a New Account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { createExpense, fetchExpenseCreateForm } from '../../api/session'
|
||||
import type { ExpenseUserOption } from '../../api/types'
|
||||
import { EXPENSE_CATEGORIES, expensePurchasedByValue } from './expenseUtils'
|
||||
import { ExpenseVendorFields } from './ExpenseVendorFields'
|
||||
|
||||
export function ExpenseCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const [retailors, setRetailors] = useState<string[]>([])
|
||||
const [users, setUsers] = useState<ExpenseUserOption[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchExpenseCreateForm()
|
||||
.then((d) => {
|
||||
if (!cancelled) {
|
||||
setRetailors(d.retailors ?? [])
|
||||
setUsers(d.users ?? [])
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load form options.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const listQs = searchParams.toString()
|
||||
const cancelHref = listQs
|
||||
? `/app/administrator/expenses?${listQs}`
|
||||
: '/app/administrator/expenses'
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
const fd = new FormData(e.currentTarget)
|
||||
if (schoolYear) fd.set('school_year', schoolYear)
|
||||
if (semester) fd.set('semester', semester)
|
||||
try {
|
||||
await createExpense(fd)
|
||||
navigate(cancelHref)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Submit failed.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h1>Add Expense / Purchase</h1>
|
||||
|
||||
{loading ? <p className="text-muted">Loading form…</p> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
{!loading && (
|
||||
<form onSubmit={onSubmit} encType="multipart/form-data">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Category</label>
|
||||
<select name="category" className="form-control form-select" required defaultValue="Expense">
|
||||
{EXPENSE_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c === 'Donation' ? 'Donation (non-reimbursable)' : c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-text text-muted">
|
||||
Use Donation when someone gifts items to the school so it is counted as an expense but skipped
|
||||
from reimbursement queues.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Amount</label>
|
||||
<input type="number" step="0.01" name="amount" className="form-control" required />
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Vendor</label>
|
||||
<ExpenseVendorFields retailors={retailors} initialRetailor="" />
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Purchased By</label>
|
||||
<select name="purchased_by" className="form-control form-select" required defaultValue="">
|
||||
<option value="">-- Select --</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={expensePurchasedByValue(u)}>
|
||||
{`${String(u.firstname ?? '').trim()} ${String(u.lastname ?? '').trim()}`.trim()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Date of Purchase</label>
|
||||
<input type="date" name="date_of_purchase" className="form-control" />
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Description</label>
|
||||
<textarea name="description" className="form-control" rows={4} />
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Receipt Image</label>
|
||||
<input type="file" name="receipt" className="form-control" accept=".jpg,.jpeg,.png,.webp,.gif,.pdf" required />
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Submitting…' : 'Submit'}
|
||||
</button>
|
||||
<Link to={cancelHref} className="btn btn-secondary ms-2">
|
||||
Cancel
|
||||
</Link>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchExpenseEdit, updateExpense } from '../../api/session'
|
||||
import type { ExpenseDetail, ExpenseUserOption } from '../../api/types'
|
||||
import { EXPENSE_CATEGORIES, expensePurchasedByValue, toDateInputValue } from './expenseUtils'
|
||||
import { ExpenseVendorFields } from './ExpenseVendorFields'
|
||||
|
||||
export function ExpenseEditPage() {
|
||||
const { expenseId } = useParams()
|
||||
const id = Number(expenseId ?? '')
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const listQs = searchParams.toString()
|
||||
const cancelHref = listQs
|
||||
? `/app/administrator/expenses?${listQs}`
|
||||
: '/app/administrator/expenses'
|
||||
|
||||
const [retailors, setRetailors] = useState<string[]>([])
|
||||
const [users, setUsers] = useState<ExpenseUserOption[]>([])
|
||||
const [expense, setExpense] = useState<ExpenseDetail | null>(null)
|
||||
const [receiptUrl, setReceiptUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
setError('Invalid expense.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fetchExpenseEdit(id)
|
||||
.then((d) => {
|
||||
if (cancelled) return
|
||||
setExpense(d.expense)
|
||||
setRetailors(d.retailors ?? [])
|
||||
setUsers(d.users ?? [])
|
||||
setReceiptUrl(d.receipt_url ?? null)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load expense.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id])
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (!Number.isFinite(id) || id <= 0) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
const form = e.currentTarget
|
||||
const fd = new FormData(form)
|
||||
const fileInput = form.querySelector<HTMLInputElement>('input[name="receipt"]')
|
||||
const file = fileInput?.files?.[0]
|
||||
if (!file) {
|
||||
fd.delete('receipt')
|
||||
}
|
||||
const remove = form.querySelector<HTMLInputElement>('input[name="remove_receipt"]')
|
||||
if (!remove?.checked) {
|
||||
fd.delete('remove_receipt')
|
||||
}
|
||||
try {
|
||||
await updateExpense(id, fd)
|
||||
navigate(cancelHref)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const retailorInitial = expense
|
||||
? String(expense.retailor ?? expense.retailer ?? '')
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h2>Edit Expense #{Number.isFinite(id) ? id : '—'}</h2>
|
||||
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
{!loading && expense ? (
|
||||
<form onSubmit={onSubmit} encType="multipart/form-data">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Category</label>
|
||||
<select
|
||||
name="category"
|
||||
className="form-control form-select"
|
||||
required
|
||||
defaultValue={expense.category}
|
||||
>
|
||||
{EXPENSE_CATEGORIES.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt === 'Donation' ? 'Donation (non-reimbursable)' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-text text-muted">
|
||||
Donation entries are tracked as expenses but will not move through reimbursement batches.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="amount"
|
||||
className="form-control"
|
||||
required
|
||||
defaultValue={String(expense.amount ?? '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Vendor</label>
|
||||
<ExpenseVendorFields
|
||||
key={retailorInitial}
|
||||
retailors={retailors}
|
||||
initialRetailor={retailorInitial}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Purchased By</label>
|
||||
<select
|
||||
name="purchased_by"
|
||||
className="form-control form-select"
|
||||
required
|
||||
defaultValue={(() => {
|
||||
const uid = Number(expense.purchased_by)
|
||||
const match = users.find((u) => Number(u.id) === uid)
|
||||
return match ? expensePurchasedByValue(match) : ''
|
||||
})()}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={expensePurchasedByValue(u)}>
|
||||
{`${String(u.firstname ?? '').trim()} ${String(u.lastname ?? '').trim()}`.trim()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Date of Purchase</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_of_purchase"
|
||||
className="form-control"
|
||||
defaultValue={toDateInputValue(
|
||||
expense.date_of_purchase ??
|
||||
expense.purchase_date ??
|
||||
expense.purchased_at ??
|
||||
expense.date_purchased,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
className="form-control"
|
||||
rows={4}
|
||||
defaultValue={expense.description ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Receipt (optional to replace)</label>
|
||||
{receiptUrl ? (
|
||||
<div className="mb-2">
|
||||
<a href={receiptUrl} target="_blank" rel="noreferrer">
|
||||
Current receipt
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
type="file"
|
||||
name="receipt"
|
||||
className="form-control"
|
||||
accept=".jpg,.jpeg,.png,.webp,.gif,.pdf"
|
||||
/>
|
||||
{expense.receipt_path ? (
|
||||
<div className="form-check mt-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
name="remove_receipt"
|
||||
value="1"
|
||||
id="rmr"
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="rmr">
|
||||
Remove existing receipt
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<Link to={cancelHref} className="btn btn-secondary ms-2">
|
||||
Cancel
|
||||
</Link>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Vendor / retailor UX from CodeIgniter `expenses/create.php` and `edit.php`
|
||||
* (dropdown + Other text field).
|
||||
*/
|
||||
export function ExpenseVendorFields({
|
||||
retailors,
|
||||
initialRetailor,
|
||||
inputName = 'retailor',
|
||||
selectId = 'retailor_select',
|
||||
inputId = 'retailor_input',
|
||||
}: {
|
||||
retailors: string[]
|
||||
initialRetailor: string
|
||||
inputName?: string
|
||||
selectId?: string
|
||||
inputId?: string
|
||||
}) {
|
||||
const list = useMemo(() => (Array.isArray(retailors) ? retailors : []).filter(Boolean), [retailors])
|
||||
const [selectVal, setSelectVal] = useState<string>('')
|
||||
const [inputVal, setInputVal] = useState(initialRetailor)
|
||||
|
||||
useEffect(() => {
|
||||
const cur = String(initialRetailor ?? '').trim()
|
||||
setInputVal(initialRetailor ?? '')
|
||||
if (!cur) {
|
||||
setSelectVal('')
|
||||
return
|
||||
}
|
||||
if (list.includes(cur)) setSelectVal(cur)
|
||||
else setSelectVal('_other')
|
||||
}, [initialRetailor, list])
|
||||
|
||||
function onSelectChange(val: string) {
|
||||
setSelectVal(val)
|
||||
if (val === '_other') return
|
||||
if (val === '') return
|
||||
setInputVal(val)
|
||||
}
|
||||
|
||||
const pickedFromList = selectVal !== '' && selectVal !== '_other'
|
||||
|
||||
return (
|
||||
<>
|
||||
<select
|
||||
id={selectId}
|
||||
className="form-control form-select"
|
||||
value={selectVal}
|
||||
onChange={(e) => onSelectChange(e.target.value)}
|
||||
>
|
||||
<option value="">-- Select Vendor --</option>
|
||||
{list.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
<option value="_other">Other</option>
|
||||
</select>
|
||||
{pickedFromList ? (
|
||||
<input type="hidden" name={inputName} value={inputVal} />
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
id={inputId}
|
||||
name={inputName}
|
||||
className="form-control mt-2"
|
||||
placeholder="Enter vendor"
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { apiUrl } from '../../lib/apiOrigin'
|
||||
import { fetchExpensesList, updateExpenseStatus } from '../../api/session'
|
||||
import type { ExpenseRow } from '../../api/types'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
formatCreatedAt,
|
||||
formatMoney,
|
||||
purchaseDateDisplay,
|
||||
totalExpenseAmount,
|
||||
vendorDisplay,
|
||||
} from './expenseUtils'
|
||||
|
||||
function receiptHref(row: ExpenseRow): string | null {
|
||||
if (row.receipt_url) return row.receipt_url
|
||||
const p = row.receipt_path
|
||||
if (!p) return null
|
||||
const path = String(p).replace(/^\/+/, '')
|
||||
return apiUrl(`/receipts/${path}`)
|
||||
}
|
||||
|
||||
function isDonation(row: ExpenseRow): boolean {
|
||||
return String(row.category ?? '').toLowerCase() === 'donation'
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
const s = String(status).toLowerCase()
|
||||
if (s === 'approved') return 'success'
|
||||
if (s === 'denied') return 'danger'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function ExpensesIndexPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? undefined
|
||||
const semester = searchParams.get('semester') ?? undefined
|
||||
const filterQs = searchParams.toString()
|
||||
|
||||
const [rows, setRows] = useState<ExpenseRow[]>([])
|
||||
const [schoolYears, setSchoolYears] = useState<string[] | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [flash, setFlash] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchExpensesList({ school_year: schoolYear, semester })
|
||||
.then((d) => {
|
||||
if (cancelled) return
|
||||
setRows(d.expenses ?? [])
|
||||
setSchoolYears(d.school_years)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load expenses.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, semester])
|
||||
|
||||
async function handleExpenseAction(id: number, status: 'approved' | 'denied') {
|
||||
if (!window.confirm(`Are you sure you want to ${status} this expense?`)) return
|
||||
try {
|
||||
const res = await updateExpenseStatus({ id, status })
|
||||
const body = res as { success?: boolean; error?: string }
|
||||
if (body.success === false || body.error) {
|
||||
setFlash(body.error ?? 'Action failed.')
|
||||
return
|
||||
}
|
||||
setFlash(null)
|
||||
setLoading(true)
|
||||
fetchExpensesList({ school_year: schoolYear, semester })
|
||||
.then((d) => {
|
||||
setRows(d.expenses ?? [])
|
||||
setSchoolYears(d.school_years)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
} catch (e: unknown) {
|
||||
setFlash(e instanceof ApiHttpError ? e.message : 'Server error.')
|
||||
}
|
||||
}
|
||||
|
||||
const total = totalExpenseAmount(rows)
|
||||
|
||||
const createLink = filterQs
|
||||
? `/app/administrator/expenses/create?${filterQs}`
|
||||
: '/app/administrator/expenses/create'
|
||||
|
||||
return (
|
||||
<div className="container-fluid mt-4">
|
||||
<h2 className="text-center mt-4 mb-3">Expense / Purchase</h2>
|
||||
<AcademicFilterBar schoolYears={schoolYears} />
|
||||
|
||||
<div className="d-flex gap-2 mb-3 justify-content-end">
|
||||
<Link to={createLink} className="btn btn-success mb-3">
|
||||
Add New
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{flash ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{flash}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
{!loading && error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table
|
||||
className="table table-bordered table-striped align-middle w-100 no-mgmt-sticky"
|
||||
style={{ tableLayout: 'auto' }}
|
||||
>
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Amount</th>
|
||||
<th>Vendor</th>
|
||||
<th>Purchased By</th>
|
||||
<th>Date of Purchase</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Approved By</th>
|
||||
<th>Receipt</th>
|
||||
<th>Added On</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((e) => {
|
||||
const href = receiptHref(e)
|
||||
const purchaser = [e.purchaser_firstname, e.purchaser_lastname]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
const approver = [e.approver_firstname, e.approver_lastname]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
const donation = isDonation(e)
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td>
|
||||
{e.category}
|
||||
{donation ? (
|
||||
<span className="badge bg-info text-dark ms-1">Non-reimbursable</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{formatMoney(e.amount)}</td>
|
||||
<td>{vendorDisplay(e)}</td>
|
||||
<td>{purchaser || 'N/A'}</td>
|
||||
<td>{purchaseDateDisplay(e)}</td>
|
||||
<td>{e.description ?? ''}</td>
|
||||
<td>
|
||||
<span className={`badge bg-${statusBadgeClass(e.status)}`}>
|
||||
{e.status ? e.status.charAt(0).toUpperCase() + e.status.slice(1).toLowerCase() : '—'}
|
||||
</span>
|
||||
{donation ? (
|
||||
<span className="badge bg-info text-dark ms-1">Donation</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{approver || '—'}</td>
|
||||
<td>
|
||||
{href ? (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
View receipt
|
||||
</a>
|
||||
) : (
|
||||
'N/A'
|
||||
)}
|
||||
</td>
|
||||
<td>{formatCreatedAt(e.created_at)}</td>
|
||||
<td className="d-flex gap-2 flex-wrap">
|
||||
<Link
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
to={`/app/administrator/expenses/${e.id}/edit${filterQs ? `?${filterQs}` : ''}`}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
{donation ? (
|
||||
<span className="text-muted small align-self-center">
|
||||
Donation – no reimbursement actions
|
||||
</span>
|
||||
) : String(e.status).toLowerCase() === 'pending' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => void handleExpenseAction(e.id, 'approved')}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => void handleExpenseAction(e.id, 'denied')}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="table-light">
|
||||
<tr>
|
||||
<th>Total</th>
|
||||
<th>{formatMoney(total)}</th>
|
||||
<th />
|
||||
<th />
|
||||
<th />
|
||||
<th />
|
||||
<th />
|
||||
<th />
|
||||
<th />
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ExpenseRow, ExpenseUserOption } from '../../api/types'
|
||||
|
||||
export const EXPENSE_CATEGORIES = ['Expense', 'Purchase', 'Reimbursement', 'Donation'] as const
|
||||
|
||||
export function expensePurchasedByValue(u: ExpenseUserOption): string {
|
||||
const name = `${String(u.firstname ?? '').trim()} ${String(u.lastname ?? '').trim()}`.trim()
|
||||
return `${u.id}|${name}`
|
||||
}
|
||||
|
||||
export function formatMoney(amount: number | string | undefined | null): string {
|
||||
const n = typeof amount === 'string' ? parseFloat(amount) : Number(amount)
|
||||
if (Number.isNaN(n)) return '$0.00'
|
||||
return `$${n.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function vendorDisplay(row: ExpenseRow): string {
|
||||
const v = row.retailor ?? row.retailer ?? ''
|
||||
return String(v).trim() || 'N/A'
|
||||
}
|
||||
|
||||
export function purchaseDateDisplay(row: ExpenseRow): string {
|
||||
const raw =
|
||||
row.date_of_purchase ??
|
||||
row.purchase_date ??
|
||||
row.purchased_at ??
|
||||
row.date_purchased ??
|
||||
row.created_at ??
|
||||
null
|
||||
return formatMdY(raw)
|
||||
}
|
||||
|
||||
export function formatMdY(raw?: string | null): string {
|
||||
if (raw == null || String(raw).trim() === '') return '-'
|
||||
const s = String(raw)
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (iso) return `${iso[2]}-${iso[3]}-${iso[1]}`
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? s : `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}-${d.getFullYear()}`
|
||||
}
|
||||
|
||||
export function formatCreatedAt(raw?: string | null): string {
|
||||
if (raw == null || String(raw).trim() === '') return ''
|
||||
const s = String(raw)
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})/.exec(s)
|
||||
if (m) return `${m[2]}-${m[3]}-${m[1]} ${m[4]}:${m[5]}`
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return s
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd}-${d.getFullYear()} ${hh}:${min}`
|
||||
}
|
||||
|
||||
export function toDateInputValue(raw?: string | null): string {
|
||||
if (!raw) return ''
|
||||
const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(raw))
|
||||
return m ? m[1] : ''
|
||||
}
|
||||
|
||||
export function totalExpenseAmount(rows: ExpenseRow[]): number {
|
||||
return rows.reduce((sum, e) => {
|
||||
const n = typeof e.amount === 'string' ? parseFloat(e.amount) : Number(e.amount)
|
||||
return sum + (Number.isNaN(n) ? 0 : n)
|
||||
}, 0)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ExpensesIndexPage } from './ExpensesIndexPage'
|
||||
export { ExpenseCreatePage } from './ExpenseCreatePage'
|
||||
export { ExpenseEditPage } from './ExpenseEditPage'
|
||||
@@ -0,0 +1,129 @@
|
||||
/* Scoped from CI `Views/family/card.php` */
|
||||
.family-card-root {
|
||||
font-size: 1rem;
|
||||
color: #12344d;
|
||||
}
|
||||
.family-card-root .fc-header {
|
||||
background: linear-gradient(135deg, #2d89ec, #2161a7);
|
||||
color: #fff;
|
||||
border-bottom: 0;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
}
|
||||
.family-card-root .fc-title {
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.family-card-root .fc-name {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
color: #0b5ed7;
|
||||
}
|
||||
.family-card-root .fc-name:hover {
|
||||
color: #084298;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.family-card-root .fc-badges .badge {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.family-card-root .nav-tabs {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.family-card-root .nav-tabs .nav-link {
|
||||
color: #2161a7;
|
||||
font-weight: 600;
|
||||
}
|
||||
.family-card-root .nav-tabs .nav-link:hover {
|
||||
color: #0b5ed7;
|
||||
}
|
||||
.family-card-root .nav-tabs .nav-link.active {
|
||||
color: #0b5ed7;
|
||||
border-color: #2d89ec #2d89ec #fff;
|
||||
}
|
||||
.family-card-root h6 {
|
||||
font-size: 1.05rem;
|
||||
color: #143b63;
|
||||
}
|
||||
.family-card-root .table thead th {
|
||||
background: #f1f5fb;
|
||||
color: #143b63;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.family-card-root .table tbody td {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.family-card-root .list-group-item {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.family-card-root .badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.family-card-root .nav-tabs {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.family-card-root .fc-table-stack thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
.family-card-root .fc-table-stack td {
|
||||
vertical-align: top;
|
||||
}
|
||||
.family-card-root .fc-table-stack td[data-label] {
|
||||
word-break: break-word;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.family-card-root {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
.family-card-root .fc-header .d-flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
.family-card-root .fc-badges {
|
||||
width: 100%;
|
||||
}
|
||||
.family-card-root .fc-badges .badge {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.family-card-root .nav-tabs .nav-link {
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.family-card-root .table-responsive {
|
||||
margin: 0 -0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.family-card-root .fc-table-stack thead {
|
||||
display: none;
|
||||
}
|
||||
.family-card-root .fc-table-stack tbody tr {
|
||||
display: block;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.family-card-root .fc-table-stack tbody tr:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.family-card-root .fc-table-stack tbody td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0 !important;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
.family-card-root .fc-table-stack tbody td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { FamilyRow, FamilyStudentRow } from '../../api/types'
|
||||
import {
|
||||
cardTitle,
|
||||
financeSummary,
|
||||
formatMdY,
|
||||
formatMoney,
|
||||
guardianAddress,
|
||||
relationLabel,
|
||||
} from './familyUtils'
|
||||
import './FamilyCardBody.css'
|
||||
|
||||
function gradeOf(s: FamilyStudentRow): string | undefined {
|
||||
const x = s as FamilyStudentRow & { grade?: string | null }
|
||||
return x.grade != null && String(x.grade).trim() !== '' ? String(x.grade) : undefined
|
||||
}
|
||||
|
||||
type EcRow = {
|
||||
parent_id?: number
|
||||
parent_label?: string | null
|
||||
emergency_contact_name?: string | null
|
||||
relation?: string | null
|
||||
cellphone?: string | null
|
||||
email?: string | null
|
||||
updated_at?: string | null
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
/** Modal / embed port of CI `Views/family/card.php` (Students / Guardians / Emergency tabs). */
|
||||
export function FamilyCardBody({
|
||||
family: f,
|
||||
returnUrl,
|
||||
}: {
|
||||
family: FamilyRow
|
||||
returnUrl: string
|
||||
}) {
|
||||
const title = cardTitle(f)
|
||||
const guardians = f.guardians ?? []
|
||||
const students = f.students ?? []
|
||||
const sum = financeSummary(f)
|
||||
const ecs = ((f as Record<string, unknown>).emergency_contacts as EcRow[] | undefined) ?? []
|
||||
|
||||
return (
|
||||
<div className="family-card-root" data-family-title={title}>
|
||||
<div className="p-3 fc-header">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div className="me-3">
|
||||
<div className="fw-semibold fc-title">{title}</div>
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-2 fc-badges">
|
||||
<span className="badge">Guardians: {guardians.length}</span>
|
||||
<span className="badge">Students: {students.length}</span>
|
||||
<span className="badge">Invoices: {sum.invoices_count}</span>
|
||||
<span className="badge">Balance: {formatMoney(sum.balance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="nav nav-tabs small px-3 pt-2" role="tablist">
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className="nav-link active"
|
||||
id="fc-tab-overview"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#fc-overview"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
Students
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className="nav-link"
|
||||
id="fc-tab-guardians"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#fc-guardians"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
Parents/Guardians
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className="nav-link"
|
||||
id="fc-tab-ec"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#fc-ec"
|
||||
type="button"
|
||||
role="tab"
|
||||
>
|
||||
Emergency
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="tab-content">
|
||||
<div className="tab-pane fade show active" id="fc-overview" role="tabpanel">
|
||||
<div className="p-3">
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<h6 className="mb-2">Students</h6>
|
||||
{students.length === 0 ? (
|
||||
<div className="text-muted small">No students linked.</div>
|
||||
) : (
|
||||
<ul className="list-group">
|
||||
{students.map((s) => (
|
||||
<li
|
||||
key={String(s.id ?? `${s.firstname}-${s.lastname}`)}
|
||||
className="list-group-item d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<div>
|
||||
<span className="fc-name">
|
||||
{String(s.firstname ?? '').trim()} {String(s.lastname ?? '').trim()}
|
||||
</span>
|
||||
</div>
|
||||
{gradeOf(s) ? (
|
||||
<span className="badge text-bg-secondary">{gradeOf(s)}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-pane fade" id="fc-guardians" role="tabpanel">
|
||||
<div className="p-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 className="mb-0">Guardians</h6>
|
||||
<span className="text-muted small">Total: {guardians.length}</span>
|
||||
</div>
|
||||
{guardians.length === 0 ? (
|
||||
<div className="alert alert-light border">No guardians yet.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm align-middle guardians-table fc-table-stack">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th style={{ minWidth: 220 }}>Guardian</th>
|
||||
<th style={{ minWidth: 220 }}>Contact</th>
|
||||
<th style={{ minWidth: 260 }}>Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{guardians.map((g) => {
|
||||
const gEmail = String(g.email ?? '').trim()
|
||||
const gName = `${String(g.firstname ?? '').trim()} ${String(g.lastname ?? '').trim()}`.trim()
|
||||
const composeParams = new URLSearchParams()
|
||||
if (gEmail) {
|
||||
composeParams.set('to', gEmail)
|
||||
composeParams.set('name', gName)
|
||||
composeParams.set('return_url', returnUrl)
|
||||
}
|
||||
const composeTo = `/app/administrator/family/compose-email?${composeParams.toString()}`
|
||||
return (
|
||||
<tr key={String(g.user_id ?? gName)}>
|
||||
<td data-label="Guardian">
|
||||
<div>
|
||||
<strong>
|
||||
<span className="fc-name">{gName}</span>
|
||||
</strong>
|
||||
{g.is_primary ? (
|
||||
<span className="badge text-bg-primary ms-1">
|
||||
<i className="bi bi-star-fill" aria-hidden /> Primary
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted small">
|
||||
Relation: {relationLabel(g.relation ?? '')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="small" data-label="Contact">
|
||||
<div>
|
||||
<i className="bi bi-envelope me-1 text-muted" aria-hidden />
|
||||
{gEmail ? (
|
||||
<Link to={composeTo} target="_blank" rel="noopener noreferrer">
|
||||
{gEmail}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<i className="bi bi-telephone me-1 text-muted" aria-hidden />
|
||||
{g.cellphone ? (
|
||||
<a href={`tel:${g.cellphone}`}>{g.cellphone}</a>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="small" data-label="Address">
|
||||
<i className="bi bi-geo-alt me-1 text-muted" aria-hidden />
|
||||
{guardianAddress(g)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-pane fade" id="fc-ec" role="tabpanel">
|
||||
<div className="p-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 className="mb-0">Emergency Contacts</h6>
|
||||
<span className="text-muted small">Linked to parents/guardians</span>
|
||||
</div>
|
||||
{ecs.length === 0 ? (
|
||||
<div className="alert alert-light border">No emergency contacts on file.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped table-hover align-middle fc-table-stack">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Parent</th>
|
||||
<th>Contact Name</th>
|
||||
<th>Relation</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ecs.map((ec, idx) => {
|
||||
const pid = Number(ec.parent_id ?? 0)
|
||||
const pl = String(ec.parent_label ?? '').trim()
|
||||
const ph = String(ec.cellphone ?? '').trim()
|
||||
const em = String(ec.email ?? '').trim()
|
||||
const ud = ec.updated_at ?? ec.created_at ?? ''
|
||||
return (
|
||||
<tr key={`${pid}-${idx}`}>
|
||||
<td data-label="Parent">
|
||||
<span className="fc-name">{pl || (pid ? `Parent #${pid}` : '—')}</span>
|
||||
</td>
|
||||
<td data-label="Contact Name">
|
||||
<span>{ec.emergency_contact_name ?? ''}</span>
|
||||
</td>
|
||||
<td data-label="Relation">{ec.relation ?? ''}</td>
|
||||
<td data-label="Phone">
|
||||
{ph ? (
|
||||
<a href={`tel:${ph}`}>{ph}</a>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Email">
|
||||
{em ? (
|
||||
<a href={`mailto:${em}`}>{em}</a>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Updated">{ud ? formatMdY(ud) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { type FormEvent, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { sendFamilyComposeEmail } from '../../api/session'
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** Plain text → simple HTML (parity with CI rich body when TinyMCE is not loaded). */
|
||||
function plainTextToHtml(text: string): string {
|
||||
const esc = escapeHtml(text)
|
||||
const parts = esc.split('\n')
|
||||
return `<div>${parts.map((line) => (line === '' ? '<br />' : `<p>${line}</p>`)).join('')}</div>`
|
||||
}
|
||||
|
||||
export function FamilyComposeEmailPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const name = searchParams.get('name') ?? ''
|
||||
const returnUrlParam = searchParams.get('return_url') ?? ''
|
||||
const [toField, setToField] = useState(() => searchParams.get('to') ?? '')
|
||||
|
||||
const safeReturn = useMemo(() => {
|
||||
if (returnUrlParam.startsWith('/app/')) return returnUrlParam
|
||||
return '/app/administrator/family'
|
||||
}, [returnUrlParam])
|
||||
|
||||
const [subject, setSubject] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
const html = plainTextToHtml(body)
|
||||
try {
|
||||
await sendFamilyComposeEmail({
|
||||
to: toField.trim(),
|
||||
subject: subject.trim(),
|
||||
html,
|
||||
return_url: safeReturn,
|
||||
})
|
||||
navigate(safeReturn)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Send failed.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container my-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="h4 mb-1">Compose Email</h2>
|
||||
{name ? <div className="text-muted">{name}</div> : null}
|
||||
</div>
|
||||
<Link to={safeReturn} className="btn btn-outline-secondary btn-sm">
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form className="card shadow-sm" onSubmit={onSubmit}>
|
||||
<div className="card-body">
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="composeTo">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
id="composeTo"
|
||||
name="to"
|
||||
value={toField}
|
||||
onChange={(e) => setToField(e.target.value)}
|
||||
required
|
||||
placeholder="parent@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="composeSubject">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="composeSubject"
|
||||
name="subject"
|
||||
placeholder="Subject"
|
||||
required
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="composeEditor">
|
||||
Body
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="composeEditor"
|
||||
rows={14}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write your message…"
|
||||
/>
|
||||
<div className="form-text">
|
||||
Message is sent as HTML (line breaks preserved). For rich formatting, paste HTML or extend
|
||||
this screen with an editor later.
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting || !toField.trim()}>
|
||||
{submitting ? 'Sending…' : 'Send Email'}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <div className="alert alert-danger mt-3 mb-0">{error}</div> : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
fetchFamilyLegacyImportMeta,
|
||||
submitFamilyLegacyImport,
|
||||
} from '../../api/session'
|
||||
import type {
|
||||
FamilyLegacyImportMetaResponse,
|
||||
FamilyLegacyImportResult,
|
||||
FamilyLegacyImportRowError,
|
||||
} from '../../api/types'
|
||||
|
||||
const DEFAULT_INSTRUCTIONS =
|
||||
'Upload a CSV or Excel export from the legacy system. The API validates rows and creates or updates family records. Use dry run first when available to preview without writing.'
|
||||
|
||||
export function FamilyLegacyImportPage() {
|
||||
const [meta, setMeta] = useState<FamilyLegacyImportMetaResponse | null>(null)
|
||||
const [metaError, setMetaError] = useState<string | null>(null)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [dryRun, setDryRun] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<FamilyLegacyImportResult | null>(null)
|
||||
|
||||
const loadMeta = useCallback(async () => {
|
||||
setMetaError(null)
|
||||
try {
|
||||
const raw = await fetchFamilyLegacyImportMeta()
|
||||
setMeta(raw)
|
||||
} catch {
|
||||
setMeta(null)
|
||||
setMetaError(
|
||||
'Could not load import instructions from the server (GET may be unconfigured). You can still upload using defaults.',
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadMeta()
|
||||
}, [loadMeta])
|
||||
|
||||
const acceptAttr = useMemo(() => {
|
||||
const exts = meta?.accepted_extensions?.filter(Boolean)
|
||||
if (exts?.length) return exts.map((e) => (e.startsWith('.') ? e : `.${e}`)).join(',')
|
||||
return '.csv,.xlsx,.xls'
|
||||
}, [meta])
|
||||
|
||||
const maxBytes = meta?.max_file_bytes ?? undefined
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setResult(null)
|
||||
if (!file) {
|
||||
setError('Choose a file to import.')
|
||||
return
|
||||
}
|
||||
if (maxBytes != null && file.size > maxBytes) {
|
||||
setError(`File exceeds maximum size (${maxBytes} bytes).`)
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.set('file', file)
|
||||
if (dryRun && meta?.dry_run_supported !== false) {
|
||||
fd.set('dry_run', '1')
|
||||
}
|
||||
const res = await submitFamilyLegacyImport(fd)
|
||||
setResult(res)
|
||||
if (res.ok === false && !res.message) {
|
||||
setError('Import completed with errors. Review the table below.')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const templateHref = meta?.template_url ?? meta?.template_download_url ?? ''
|
||||
const instructionsText = meta?.instructions?.trim() || DEFAULT_INSTRUCTIONS
|
||||
const rowErrors: FamilyLegacyImportRowError[] =
|
||||
result?.errors ?? result?.validation_errors ?? []
|
||||
|
||||
const imported = result?.imported ?? result?.imported_count
|
||||
const skipped = result?.skipped ?? result?.skipped_count
|
||||
const resultOk = result?.ok !== false && rowErrors.length === 0
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-4" style={{ maxWidth: 720 }}>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="breadcrumb">
|
||||
<li className="breadcrumb-item">
|
||||
<Link to="/app/administrator/family">Families</Link>
|
||||
</li>
|
||||
<li className="breadcrumb-item active" aria-current="page">
|
||||
Import legacy
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 className="h3 mb-2">Import legacy families</h1>
|
||||
<p className="text-muted small mb-4">
|
||||
Uses <code>GET</code> / <code>POST</code> <code>/api/v1/families/import-legacy</code> with JWT{' '}
|
||||
<code>Authorization: Bearer</code>. Field name for upload: <code>file</code>.
|
||||
</p>
|
||||
|
||||
{metaError ? <div className="alert alert-warning">{metaError}</div> : null}
|
||||
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header">
|
||||
<strong>Instructions</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{meta?.instructions_html ? (
|
||||
<div
|
||||
className="small"
|
||||
// eslint-disable-next-line react/no-danger -- API-provided HTML when present
|
||||
dangerouslySetInnerHTML={{ __html: meta.instructions_html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="small mb-0" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{instructionsText}
|
||||
</p>
|
||||
)}
|
||||
{meta?.columns?.length ? (
|
||||
<div className="mt-3">
|
||||
<div className="fw-semibold small">Expected columns</div>
|
||||
<code className="small">{meta.columns.join(', ')}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{templateHref ? (
|
||||
<a className="btn btn-sm btn-outline-secondary mt-3" href={templateHref} target="_blank" rel="noreferrer">
|
||||
Download template
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">
|
||||
<strong>Upload</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="legacy-import-file">
|
||||
File
|
||||
</label>
|
||||
<input
|
||||
id="legacy-import-file"
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept={acceptAttr}
|
||||
required
|
||||
disabled={submitting}
|
||||
onChange={(ev) => setFile(ev.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{maxBytes != null ? (
|
||||
<div className="form-text">Maximum size: {maxBytes.toLocaleString()} bytes.</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(meta?.dry_run_supported ?? true) ? (
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
id="legacy-dry-run"
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={dryRun}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setDryRun(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="legacy-dry-run">
|
||||
Dry run (validate only; sends <code>dry_run=1</code>)
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Uploading…' : 'Run import'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result ? (
|
||||
<div className="mt-4 pt-3 border-top">
|
||||
<div
|
||||
className={`alert mb-3 ${resultOk ? 'alert-success' : 'alert-warning'}`}
|
||||
role="status"
|
||||
>
|
||||
{result.message ?? 'Import finished.'}
|
||||
</div>
|
||||
<div className="row g-2 small mb-3">
|
||||
{imported != null ? (
|
||||
<div className="col-auto">
|
||||
<span className="text-muted">Imported:</span>{' '}
|
||||
<strong>{imported}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
{skipped != null ? (
|
||||
<div className="col-auto">
|
||||
<span className="text-muted">Skipped:</span>{' '}
|
||||
<strong>{skipped}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
{result.failed != null ? (
|
||||
<div className="col-auto">
|
||||
<span className="text-muted">Failed:</span>{' '}
|
||||
<strong>{result.failed}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{rowErrors.length > 0 ? (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-bordered mb-0">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Row</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rowErrors.map((row, i) => (
|
||||
<tr key={`${row.row ?? row.line ?? i}-${i}`}>
|
||||
<td>{row.row ?? row.line ?? '—'}</td>
|
||||
<td>{row.message ?? row.error ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { FamilyGuardianRow, FamilyRow, FamilyStudentRow } from '../../api/types'
|
||||
import {
|
||||
financeSummary,
|
||||
formatMdY,
|
||||
formatMoney,
|
||||
guardianAddress,
|
||||
relationLabel,
|
||||
} from './familyUtils'
|
||||
|
||||
type InvoiceRow = {
|
||||
invoice_number?: string | null
|
||||
status?: string | null
|
||||
total_amount?: number | string | null
|
||||
paid_amount?: number | string | null
|
||||
balance?: number | string | null
|
||||
issue_date?: string | null
|
||||
}
|
||||
|
||||
type PaymentRow = {
|
||||
invoice_id?: number | string | null
|
||||
paid_amount?: number | string | null
|
||||
balance?: number | string | null
|
||||
payment_method?: string | null
|
||||
payment_date?: string | null
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
function gradeOf(s: FamilyStudentRow): string | undefined {
|
||||
const x = s as FamilyStudentRow & { grade?: string | null }
|
||||
return x.grade != null && String(x.grade).trim() !== '' ? String(x.grade) : undefined
|
||||
}
|
||||
|
||||
export function FamilyListCard({
|
||||
family: f,
|
||||
searchBlobAttr,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
family: FamilyRow
|
||||
searchBlobAttr: string
|
||||
onOpenDetails: () => void
|
||||
}) {
|
||||
const guardians = f.guardians ?? []
|
||||
const students = f.students ?? []
|
||||
const sum = financeSummary(f)
|
||||
|
||||
const fr = f as Record<string, unknown>
|
||||
const invoices = (fr.invoices as InvoiceRow[] | undefined) ?? []
|
||||
const payments = (fr.payments as PaymentRow[] | undefined) ?? []
|
||||
const invoiceMap = (fr.invoice_map as Record<string, string> | undefined) ?? {}
|
||||
|
||||
const $__hasDetails = [
|
||||
f.address_line1,
|
||||
f.address_line2,
|
||||
f.city,
|
||||
f.state,
|
||||
f.postal_code,
|
||||
f.country,
|
||||
f.primary_phone,
|
||||
f.preferred_lang,
|
||||
f.preferred_contact_method,
|
||||
].some((x) => String(x ?? '').trim() !== '')
|
||||
|
||||
const hasInvoices = invoices.length > 0
|
||||
const hasPayments = payments.length > 0
|
||||
const half = hasInvoices && hasPayments
|
||||
|
||||
return (
|
||||
<div className="card mb-3 family-card" data-search={searchBlobAttr}>
|
||||
<div className="card-header">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center">
|
||||
<div className="mb-2 mb-sm-0">
|
||||
<strong>{f.household_name?.trim() || `Family #${f.id}`}</strong>
|
||||
{f.is_primary_home ? (
|
||||
<span className="badge bg-primary ms-2">Primary</span>
|
||||
) : null}
|
||||
<span className="badge bg-light text-dark ms-2">Code: {f.family_code ?? '—'}</span>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<span className="badge bg-secondary me-1">Guardians: {guardians.length}</span>
|
||||
<span className="badge bg-secondary me-1">Students: {students.length}</span>
|
||||
<span className="badge bg-secondary me-1">Invoices: {sum.invoices_count}</span>
|
||||
<span
|
||||
className={`badge ${sum.balance > 0 ? 'bg-danger' : 'bg-success'}`}
|
||||
>
|
||||
Balance: {formatMoney(sum.balance)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row g-2">
|
||||
<div className="col-lg-4 order-lg-2">
|
||||
{$__hasDetails ? (
|
||||
<>
|
||||
<h6 className="mb-2">Family Details</h6>
|
||||
<div className="small text-muted">Address</div>
|
||||
<div>
|
||||
{f.address_line1 ?? ''} {f.address_line2 ?? ''}
|
||||
</div>
|
||||
<div>
|
||||
{f.city ?? ''} {f.state ?? ''} {f.postal_code ?? ''}
|
||||
</div>
|
||||
{f.country ? <div>{f.country}</div> : null}
|
||||
<div className="mt-2">
|
||||
<span className="text-muted small">Phone:</span> {f.primary_phone ?? '—'}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="text-muted small">Lang:</span> {f.preferred_lang ?? '—'}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="text-muted small">Contact:</span>{' '}
|
||||
{f.preferred_contact_method ?? '—'}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="text-muted small">Active:</span>{' '}
|
||||
{f.is_active ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<h6 className="mb-2">Students</h6>
|
||||
{students.length === 0 ? (
|
||||
<div className="text-muted small">No students linked.</div>
|
||||
) : (
|
||||
<ul className="list-group students-list">
|
||||
{students.map((s) => (
|
||||
<li key={String(s.id)} className="list-group-item student-item">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0 text-decoration-none text-start"
|
||||
onClick={onOpenDetails}
|
||||
>
|
||||
{String(s.firstname ?? '').trim()} {String(s.lastname ?? '').trim()}
|
||||
</button>
|
||||
{gradeOf(s) ? (
|
||||
<span className="text-muted small"> • {gradeOf(s)}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-8 order-lg-1">
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 className="mb-0">Guardians</h6>
|
||||
<span className="text-muted small">Total: {guardians.length}</span>
|
||||
</div>
|
||||
{guardians.length === 0 ? (
|
||||
<div className="alert alert-light border">No guardians yet.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm align-middle guardians-table">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th style={{ minWidth: 220 }}>Guardian</th>
|
||||
<th style={{ minWidth: 220 }}>Contact</th>
|
||||
<th style={{ minWidth: 260 }}>Address</th>
|
||||
<th style={{ minWidth: 140 }}>Prefs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{guardians.map((g: FamilyGuardianRow) => (
|
||||
<tr key={String(g.user_id ?? `${g.firstname}-${g.lastname}`)} className="guard-row">
|
||||
<td>
|
||||
<div>
|
||||
<strong>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0 text-decoration-none"
|
||||
onClick={onOpenDetails}
|
||||
>
|
||||
{String(g.firstname ?? '').trim()} {String(g.lastname ?? '').trim()}
|
||||
</button>
|
||||
</strong>
|
||||
{g.is_primary ? (
|
||||
<span className="badge text-bg-primary ms-1">
|
||||
<i className="bi bi-star-fill" aria-hidden /> Primary
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted small">
|
||||
Relation: {relationLabel(g.relation ?? '')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="small">
|
||||
<div>
|
||||
<i className="bi bi-envelope me-1 text-muted" aria-hidden />
|
||||
{g.email ? (
|
||||
<a href={`mailto:${g.email}`}>{g.email}</a>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<i className="bi bi-telephone me-1 text-muted" aria-hidden />
|
||||
{g.cellphone ? (
|
||||
<a href={`tel:${g.cellphone}`}>{g.cellphone}</a>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="small">
|
||||
<i className="bi bi-geo-alt me-1 text-muted" aria-hidden />
|
||||
{guardianAddress(g)}
|
||||
</td>
|
||||
<td>
|
||||
{g.receive_emails ? (
|
||||
<span className="badge text-bg-success">
|
||||
<i className="bi bi-envelope-fill" aria-hidden /> Email
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge text-bg-secondary">
|
||||
<i className="bi bi-envelope" aria-hidden /> Email
|
||||
</span>
|
||||
)}
|
||||
{g.receive_sms ? (
|
||||
<span className="badge text-bg-info ms-1">
|
||||
<i className="bi bi-chat-dots-fill" aria-hidden /> SMS
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge text-bg-secondary ms-1">
|
||||
<i className="bi bi-chat-dots" aria-hidden /> SMS
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasInvoices ? (
|
||||
<div className={half ? 'col-lg-6' : 'col-12'}>
|
||||
<h6 className="mb-2">Invoices</h6>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped table-hover align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<th>Paid</th>
|
||||
<th>Balance</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((iv, i) => (
|
||||
<tr key={`${iv.invoice_number}-${i}`}>
|
||||
<td>{iv.invoice_number ?? ''}</td>
|
||||
<td>{iv.status ?? ''}</td>
|
||||
<td>{formatMoney(iv.total_amount)}</td>
|
||||
<td>{formatMoney(iv.paid_amount)}</td>
|
||||
<td>{formatMoney(iv.balance)}</td>
|
||||
<td>{iv.issue_date ? formatMdY(iv.issue_date) : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasPayments ? (
|
||||
<div className={half ? 'col-lg-6' : 'col-12'}>
|
||||
<h6 className="mb-2">Recent Payments</h6>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped table-hover align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Amount</th>
|
||||
<th>Balance</th>
|
||||
<th>Method</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((p, i) => {
|
||||
const iid = Number(p.invoice_id ?? 0)
|
||||
const invLabel =
|
||||
invoiceMap[String(iid)] ?? invoiceMap[iid] ?? `#${iid}`
|
||||
return (
|
||||
<tr key={`${iid}-${i}`}>
|
||||
<td>{invLabel}</td>
|
||||
<td>{formatMoney(p.paid_amount)}</td>
|
||||
<td>{formatMoney(p.balance)}</td>
|
||||
<td>{p.payment_method ?? ''}</td>
|
||||
<td>{p.payment_date ? formatMdY(p.payment_date) : ''}</td>
|
||||
<td>{p.status ?? ''}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import type { FamilyAdminIndexResponse, FamilyRow, FamilySearchSuggestionItem } from '../../api/types'
|
||||
import { fetchFamilyAdminIndex, fetchFamilySearchSuggestions } from '../../api/session'
|
||||
import { FamilyCardBody } from './FamilyCardBody'
|
||||
import { FamilyListCard } from './FamilyListCard'
|
||||
import { familyCardMatches, familySearchBlob, normalize } from './familyUtils'
|
||||
|
||||
function parsePositiveInt(s: string | null): number | undefined {
|
||||
if (s == null || s === '') return undefined
|
||||
const n = Number.parseInt(s, 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined
|
||||
}
|
||||
|
||||
function buildStaticSuggestions(
|
||||
q: string,
|
||||
data: FamilyAdminIndexResponse['data'],
|
||||
): FamilySearchSuggestionItem[] {
|
||||
if (!data) return []
|
||||
const qn = normalize(q)
|
||||
const out: FamilySearchSuggestionItem[] = []
|
||||
for (const st of data.searchStudents ?? []) {
|
||||
const id = Number((st as Record<string, unknown>).id ?? 0)
|
||||
if (!id) continue
|
||||
const sf = String((st as Record<string, unknown>).firstname ?? '').trim()
|
||||
const sl = String((st as Record<string, unknown>).lastname ?? '').trim()
|
||||
const slab = `${sf} ${sl}`.trim()
|
||||
if (normalize(slab).includes(qn)) {
|
||||
out.push({ type: 'student', id, label: slab || `Student #${id}`, sub: 'Student' })
|
||||
}
|
||||
}
|
||||
for (const gu of data.searchGuardians ?? []) {
|
||||
const id = Number(gu.id ?? 0)
|
||||
if (!id) continue
|
||||
const gf = String(gu.firstname ?? '').trim()
|
||||
const gl = String(gu.lastname ?? '').trim()
|
||||
const glab = `${gf} ${gl}`.trim()
|
||||
const gel = String(gu.email ?? '').trim()
|
||||
const gph = String((gu as Record<string, unknown>).cellphone ?? '').trim()
|
||||
const gsub = `${gel} ${gph}`.trim()
|
||||
if (normalize(glab).includes(qn) || normalize(gsub).includes(qn)) {
|
||||
out.push({ type: 'guardian', id, label: glab || `Guardian #${id}`, sub: gsub })
|
||||
}
|
||||
}
|
||||
return out.slice(0, 12)
|
||||
}
|
||||
|
||||
export function FamilyManagementPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const studentId = parsePositiveInt(searchParams.get('student_id'))
|
||||
const guardianId = parsePositiveInt(searchParams.get('guardian_id'))
|
||||
|
||||
const [payload, setPayload] = useState<FamilyAdminIndexResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<FamilySearchSuggestionItem[]>([])
|
||||
const [suggestOpen, setSuggestOpen] = useState(false)
|
||||
const [modalFamily, setModalFamily] = useState<FamilyRow | null>(null)
|
||||
const suggestTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const searchWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const returnUrl = `${window.location.pathname}${window.location.search}`
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchFamilyAdminIndex({
|
||||
studentId: studentId ?? null,
|
||||
guardianId: guardianId ?? null,
|
||||
})
|
||||
.then((d) => {
|
||||
if (!cancelled) {
|
||||
setPayload(d)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load families.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [studentId, guardianId])
|
||||
|
||||
const families = payload?.data?.families ?? []
|
||||
const studentBanner = payload?.data?.student
|
||||
|
||||
const filteredFamilies = useMemo(() => {
|
||||
const q = searchInput.trim()
|
||||
return families.filter((f) => familyCardMatches(f, q))
|
||||
}, [families, searchInput])
|
||||
|
||||
const anyVisible = filteredFamilies.length > 0 || searchInput.trim() === ''
|
||||
|
||||
const refreshSuggestions = useCallback(
|
||||
async (q: string) => {
|
||||
const qt = q.trim()
|
||||
if (qt.length < 1) {
|
||||
setSuggestions([])
|
||||
setSuggestOpen(false)
|
||||
return
|
||||
}
|
||||
const api = await fetchFamilySearchSuggestions(qt)
|
||||
let items = api.items ?? []
|
||||
if (items.length === 0 && payload?.data) {
|
||||
items = buildStaticSuggestions(qt, payload.data)
|
||||
}
|
||||
setSuggestions(items.slice(0, 8))
|
||||
setSuggestOpen(items.length > 0)
|
||||
},
|
||||
[payload],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (suggestTimer.current) clearTimeout(suggestTimer.current)
|
||||
const q = searchInput.trim()
|
||||
if (q.length < 1) {
|
||||
setSuggestions([])
|
||||
setSuggestOpen(false)
|
||||
return
|
||||
}
|
||||
suggestTimer.current = setTimeout(() => {
|
||||
void refreshSuggestions(q)
|
||||
}, 150)
|
||||
return () => {
|
||||
if (suggestTimer.current) clearTimeout(suggestTimer.current)
|
||||
}
|
||||
}, [searchInput, refreshSuggestions])
|
||||
|
||||
useEffect(() => {
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const el = searchWrapRef.current
|
||||
if (!el || !(e.target instanceof Node)) return
|
||||
if (!el.contains(e.target)) setSuggestOpen(false)
|
||||
}
|
||||
document.addEventListener('click', onDocClick)
|
||||
return () => document.removeEventListener('click', onDocClick)
|
||||
}, [])
|
||||
|
||||
function pickSuggestion(it: FamilySearchSuggestionItem) {
|
||||
setSuggestOpen(false)
|
||||
setSearchInput('')
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (it.type === 'student') {
|
||||
next.set('student_id', String(it.id))
|
||||
next.delete('guardian_id')
|
||||
} else {
|
||||
next.set('guardian_id', String(it.id))
|
||||
next.delete('student_id')
|
||||
}
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid my-4">
|
||||
<div className="d-flex flex-wrap justify-content-center align-items-center gap-2 mb-3">
|
||||
<h2 className="text-center mt-4 mb-0 flex-grow-1">Family Management</h2>
|
||||
<Link className="btn btn-outline-secondary btn-sm" to="/app/families/import-legacy">
|
||||
Import legacy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="text-muted text-center">Loading…</p> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="row mb-3 justify-content-center">
|
||||
<div className="col-12 col-md-8 col-lg-6 position-relative mx-auto" ref={searchWrapRef}>
|
||||
<label htmlFor="familySearch" className="form-label">
|
||||
Search (parent, student, phone, email)
|
||||
</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-control"
|
||||
id="familySearch"
|
||||
placeholder="Type to filter…"
|
||||
autoComplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onFocus={() => {
|
||||
const v = searchInput.trim()
|
||||
if (v.length >= 1) void refreshSuggestions(v)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && suggestOpen && suggestions.length > 0) {
|
||||
e.preventDefault()
|
||||
pickSuggestion(suggestions[0])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{suggestOpen && suggestions.length > 0 ? (
|
||||
<div
|
||||
className="list-group position-absolute w-100"
|
||||
style={{ zIndex: 1030, maxHeight: 260, overflow: 'auto' }}
|
||||
role="listbox"
|
||||
>
|
||||
{suggestions.map((it) => (
|
||||
<button
|
||||
key={`${it.type}-${it.id}`}
|
||||
type="button"
|
||||
className="list-group-item list-group-item-action"
|
||||
onClick={() => pickSuggestion(it)}
|
||||
>
|
||||
<div className="d-flex justify-content-between gap-2">
|
||||
<span>{it.label}</span>
|
||||
<span className="text-muted small">{it.sub ?? ''}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!anyVisible && searchInput.trim() !== '' ? (
|
||||
<div className="alert alert-warning">No matches found.</div>
|
||||
) : null}
|
||||
|
||||
{studentBanner ? (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">Student</div>
|
||||
<div className="card-body">
|
||||
<strong>
|
||||
{String(studentBanner.firstname ?? '').trim()}{' '}
|
||||
{String(studentBanner.lastname ?? '').trim()}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && families.length === 0 ? (
|
||||
<div className="alert alert-warning">No families found.</div>
|
||||
) : null}
|
||||
|
||||
{filteredFamilies.map((f) => (
|
||||
<FamilyListCard
|
||||
key={f.id}
|
||||
family={f}
|
||||
searchBlobAttr={familySearchBlob(f)}
|
||||
onOpenDetails={() => setModalFamily(f)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{modalFamily ? (
|
||||
<div
|
||||
className="modal fade show d-block"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.35)' }}
|
||||
onClick={() => setModalFamily(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setModalFamily(null)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-dialog modal-xl modal-dialog-scrollable"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Family</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
aria-label="Close"
|
||||
onClick={() => setModalFamily(null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<FamilyCardBody family={modalFamily} returnUrl={returnUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { FamilyGuardianRow, FamilyRow, FamilyStudentRow } from '../../api/types'
|
||||
|
||||
export function normalize(s: string): string {
|
||||
return s.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function relationLabel(relRaw: string | null | undefined): string {
|
||||
const rel = String(relRaw ?? '').toLowerCase().trim()
|
||||
if (rel === 'guardian') return 'second parent'
|
||||
if (rel === 'primary') return 'first parent'
|
||||
return String(relRaw ?? '').trim()
|
||||
}
|
||||
|
||||
export function formatMoney(n: number | string | undefined | null): string {
|
||||
const x = typeof n === 'string' ? parseFloat(n) : Number(n)
|
||||
if (Number.isNaN(x)) return '$0.00'
|
||||
return `$${x.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function formatMdY(raw?: string | null): string {
|
||||
if (raw == null || String(raw).trim() === '') return ''
|
||||
const s = String(raw)
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (iso) return `${iso[2]}-${iso[3]}-${iso[1]}`
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime())
|
||||
? s
|
||||
: `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}-${d.getFullYear()}`
|
||||
}
|
||||
|
||||
export function financeSummary(f: FamilyRow): {
|
||||
invoices_count: number
|
||||
total_amount: number
|
||||
paid_amount: number
|
||||
balance: number
|
||||
} {
|
||||
const s = (f as Record<string, unknown>).finance_summary as Record<string, unknown> | undefined
|
||||
return {
|
||||
invoices_count: Number(s?.invoices_count ?? 0),
|
||||
total_amount: Number(s?.total_amount ?? 0),
|
||||
paid_amount: Number(s?.paid_amount ?? 0),
|
||||
balance: Number(s?.balance ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors PHP `index.php` search blob for client-side filtering. */
|
||||
/** Client filter matching CI `applyFilter()` (card + inner text). */
|
||||
export function familyCardMatches(f: FamilyRow, qRaw: string): boolean {
|
||||
const q = normalize(qRaw.trim())
|
||||
if (!q) return true
|
||||
if (normalize(familySearchBlob(f)).includes(q)) return true
|
||||
for (const g of f.guardians ?? []) {
|
||||
const row = [
|
||||
g.firstname,
|
||||
g.lastname,
|
||||
g.email,
|
||||
g.cellphone,
|
||||
g.relation,
|
||||
g.address_street,
|
||||
g.city,
|
||||
g.state,
|
||||
g.zip,
|
||||
]
|
||||
.map((x) => String(x ?? ''))
|
||||
.join(' ')
|
||||
if (normalize(row).includes(q)) return true
|
||||
}
|
||||
for (const s of f.students ?? []) {
|
||||
const gr = (s as FamilyStudentRow & { grade?: string }).grade
|
||||
const row = [s.firstname, s.lastname, gr].map((x) => String(x ?? '')).join(' ')
|
||||
if (normalize(row).includes(q)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function familySearchBlob(f: FamilyRow): string {
|
||||
const parts: string[] = []
|
||||
const push = (x: unknown) => {
|
||||
const t = String(x ?? '').trim()
|
||||
if (t) parts.push(t)
|
||||
}
|
||||
push(f.household_name)
|
||||
push(f.family_code)
|
||||
push(f.primary_phone)
|
||||
push(f.preferred_lang)
|
||||
push(f.preferred_contact_method)
|
||||
push(f.address_line1)
|
||||
push(f.address_line2)
|
||||
push(f.city)
|
||||
push(f.state)
|
||||
push(f.postal_code)
|
||||
push(f.country)
|
||||
for (const gg of f.guardians ?? []) {
|
||||
push(`${gg.firstname ?? ''} ${gg.lastname ?? ''}`.trim())
|
||||
push(gg.email)
|
||||
push(gg.cellphone)
|
||||
push(gg.relation)
|
||||
push(gg.address_street)
|
||||
push(gg.city)
|
||||
push(gg.state)
|
||||
push(gg.zip)
|
||||
}
|
||||
for (const ss of f.students ?? []) {
|
||||
push(`${ss.firstname ?? ''} ${ss.lastname ?? ''}`.trim())
|
||||
push((ss as FamilyStudentRow & { grade?: string }).grade)
|
||||
}
|
||||
return normalize(parts.join(' '))
|
||||
}
|
||||
|
||||
export function cardTitle(f: FamilyRow): string {
|
||||
const raw = String(f.household_name ?? '').trim()
|
||||
if (raw && !/^\s*Family\s+of\s+User\s*\d+\s*$/i.test(raw)) return raw
|
||||
const guardians = f.guardians ?? []
|
||||
const primary = guardians.find((g) => g.is_primary) ?? guardians[0]
|
||||
const ln = String(primary?.lastname ?? '').trim()
|
||||
if (ln) return `Family of ${ln}`
|
||||
return 'Family'
|
||||
}
|
||||
|
||||
export function guardianAddress(g: FamilyGuardianRow): string {
|
||||
const addrParts = [
|
||||
g.address_street ?? '',
|
||||
`${String(g.city ?? '').trim()} ${String(g.state ?? '').trim()}`.trim(),
|
||||
g.zip ?? '',
|
||||
].filter((x) => String(x).trim() !== '')
|
||||
return addrParts.length > 0 ? addrParts.join(', ') : '—'
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { FamilyManagementPage } from './FamilyManagementPage'
|
||||
export { FamilyLegacyImportPage } from './FamilyLegacyImportPage'
|
||||
export { FamilyComposeEmailPage } from './FamilyComposeEmailPage'
|
||||
export { FamilyCardBody } from './FamilyCardBody'
|
||||
@@ -0,0 +1,455 @@
|
||||
import { type FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
cancelIncidentFlag,
|
||||
closeIncidentFlag,
|
||||
createIncidentFlag,
|
||||
fetchFlagsFormMeta,
|
||||
fetchFlagsPending,
|
||||
fetchStudentsByGrade,
|
||||
type GradeOption,
|
||||
type IncidentFlagRow,
|
||||
} from '../../api/flags'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import { INCIDENT_DESCRIPTIONS, INCIDENT_ACTION_TEMPLATES } from './flagIncidentsConstants'
|
||||
import {
|
||||
descriptionForFlag,
|
||||
formatIncidentDatetime,
|
||||
incidentTypeLabel,
|
||||
lastUpdaterCell,
|
||||
} from './flagRowUtils'
|
||||
import { normalizeIncidentKey } from './incidentLabels'
|
||||
|
||||
/** CI `flags/flags_management.php` — pending incidents, update state, add incident. */
|
||||
export function FlagsManagementPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const [rows, setRows] = useState<IncidentFlagRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [flashOk, setFlashOk] = useState<string | null>(null)
|
||||
|
||||
const [grades, setGrades] = useState<GradeOption[]>([])
|
||||
const [gradesError, setGradesError] = useState<string | null>(null)
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [gradeId, setGradeId] = useState('')
|
||||
const [students, setStudents] = useState<Array<{ id: number; name: string }>>([])
|
||||
const [studentsLoading, setStudentsLoading] = useState(false)
|
||||
const [studentId, setStudentId] = useState('')
|
||||
const [incidentKey, setIncidentKey] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [descAutofill, setDescAutofill] = useState(false)
|
||||
const [addBusy, setAddBusy] = useState(false)
|
||||
|
||||
const [modalFlagId, setModalFlagId] = useState<number | null>(null)
|
||||
const [modalAction, setModalAction] = useState<'Closed' | 'Canceled' | null>(null)
|
||||
const [modalDescription, setModalDescription] = useState('')
|
||||
const [modalActionTaken, setModalActionTaken] = useState('')
|
||||
const [modalBusy, setModalBusy] = useState(false)
|
||||
|
||||
const loadPending = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetchFlagsPending(searchParams)
|
||||
.then((data) => {
|
||||
setRows(data)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load incidents.')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
loadPending()
|
||||
}, [loadPending])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFlagsFormMeta()
|
||||
.then((m) => {
|
||||
setGrades(m.grades ?? [])
|
||||
setGradesError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setGrades([])
|
||||
setGradesError(e instanceof ApiHttpError ? e.message : 'Unable to load grades.')
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gradeId) {
|
||||
setStudents([])
|
||||
setStudentId('')
|
||||
return
|
||||
}
|
||||
const id = Number(gradeId)
|
||||
if (!Number.isFinite(id)) return
|
||||
setStudentsLoading(true)
|
||||
fetchStudentsByGrade(id)
|
||||
.then((list) => {
|
||||
setStudents(list)
|
||||
setStudentId('')
|
||||
})
|
||||
.catch(() => {
|
||||
setStudents([])
|
||||
setStudentId('')
|
||||
})
|
||||
.finally(() => setStudentsLoading(false))
|
||||
}, [gradeId])
|
||||
|
||||
function onIncidentChange(next: string) {
|
||||
setIncidentKey(next)
|
||||
if (!description.trim() || descAutofill) {
|
||||
const text = INCIDENT_DESCRIPTIONS[next] ?? ''
|
||||
setDescription(text)
|
||||
setDescAutofill(Boolean(text))
|
||||
}
|
||||
}
|
||||
|
||||
function onDescriptionInput(v: string) {
|
||||
setDescription(v)
|
||||
setDescAutofill(false)
|
||||
}
|
||||
|
||||
function openStateModal(flagId: number, action: 'Closed' | 'Canceled', incidentRaw: string) {
|
||||
setModalFlagId(flagId)
|
||||
setModalAction(action)
|
||||
setModalActionTaken('')
|
||||
const key = normalizeIncidentKey(incidentRaw)
|
||||
if (action === 'Closed') {
|
||||
const text =
|
||||
INCIDENT_ACTION_TEMPLATES[key] ?? INCIDENT_ACTION_TEMPLATES[incidentRaw] ?? ''
|
||||
setModalDescription(text)
|
||||
} else {
|
||||
setModalDescription('')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalFlagId(null)
|
||||
setModalAction(null)
|
||||
setModalDescription('')
|
||||
setModalActionTaken('')
|
||||
}
|
||||
|
||||
async function submitModal() {
|
||||
if (modalFlagId == null || !modalAction) return
|
||||
setModalBusy(true)
|
||||
try {
|
||||
const body = {
|
||||
state_description: modalDescription,
|
||||
action_taken: modalActionTaken,
|
||||
}
|
||||
if (modalAction === 'Closed') {
|
||||
await closeIncidentFlag(modalFlagId, body)
|
||||
} else {
|
||||
await cancelIncidentFlag(modalFlagId, body)
|
||||
}
|
||||
setFlashOk('Incident updated.')
|
||||
closeModal()
|
||||
loadPending()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Update failed.')
|
||||
} finally {
|
||||
setModalBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onFlagStateSelect(flag: IncidentFlagRow, next: string) {
|
||||
const id = flag.id
|
||||
if (id == null) return
|
||||
if (next === 'Closed' || next === 'Canceled') {
|
||||
openStateModal(id, next, String(flag.flag ?? ''))
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!gradeId || !studentId || !incidentKey || !description.trim()) return
|
||||
setAddBusy(true)
|
||||
setFlashOk(null)
|
||||
setError(null)
|
||||
try {
|
||||
await createIncidentFlag({
|
||||
grade: gradeId,
|
||||
student: studentId,
|
||||
flag: incidentKey,
|
||||
description: description.trim(),
|
||||
school_year: schoolYear || undefined,
|
||||
semester: semester || undefined,
|
||||
})
|
||||
setFlashOk('Incident saved.')
|
||||
setShowAddForm(false)
|
||||
setGradeId('')
|
||||
setStudentId('')
|
||||
setIncidentKey('')
|
||||
setDescription('')
|
||||
setDescAutofill(false)
|
||||
loadPending()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Could not save incident.')
|
||||
} finally {
|
||||
setAddBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h2 className="text-center mt-4 mb-3">Incidents Management</h2>
|
||||
|
||||
<AcademicFilterBar />
|
||||
|
||||
{flashOk ? (
|
||||
<div className="alert alert-success" role="alert">
|
||||
{flashOk}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<h4 className="mb-3">Pending Incidents</h4>
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Last Updater</th>
|
||||
<th>Student Name</th>
|
||||
<th>Grade</th>
|
||||
<th>Incident Type</th>
|
||||
<th>Description</th>
|
||||
<th>Datetime</th>
|
||||
<th>Incident State</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>No incidents found.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((flag, i) => (
|
||||
<tr key={flag.id ?? i}>
|
||||
<td>{lastUpdaterCell(flag)}</td>
|
||||
<td>{flag.student_name ?? ''}</td>
|
||||
<td>{flag.grade ?? ''}</td>
|
||||
<td>{incidentTypeLabel(flag.flag)}</td>
|
||||
<td>{descriptionForFlag(flag)}</td>
|
||||
<td>{formatIncidentDatetime(flag.flag_datetime)}</td>
|
||||
<td>{flag.flag_state ?? ''}</td>
|
||||
<td>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value=""
|
||||
aria-label={`Update state for incident ${flag.id}`}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value
|
||||
if (v === 'Closed' || v === 'Canceled') onFlagStateSelect(flag, v)
|
||||
ev.currentTarget.value = ''
|
||||
}}
|
||||
>
|
||||
<option value="">Select Action</option>
|
||||
<option value="Closed">Closed</option>
|
||||
<option value="Canceled">Canceled</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modalFlagId != null && modalAction ? (
|
||||
<>
|
||||
<div className="modal-backdrop fade show" />
|
||||
<div className="modal fade show d-block" tabIndex={-1} role="dialog" aria-modal>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Add Description</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={closeModal} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
placeholder="Enter description here"
|
||||
value={modalDescription}
|
||||
onChange={(e) => setModalDescription(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<label className="form-label" htmlFor="flagActionTaken">
|
||||
Action Taken
|
||||
</label>
|
||||
<textarea
|
||||
id="flagActionTaken"
|
||||
className="form-control"
|
||||
rows={3}
|
||||
placeholder="Enter action taken here"
|
||||
value={modalActionTaken}
|
||||
onChange={(e) => setModalActionTaken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={modalBusy}
|
||||
onClick={() => void submitModal()}
|
||||
>
|
||||
{modalBusy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={closeModal}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="d-flex gap-2 mb-3 justify-content-end flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-success"
|
||||
onClick={() => setShowAddForm((v) => !v)}
|
||||
>
|
||||
{showAddForm ? 'Hide form' : 'Add New Incident'}
|
||||
</button>
|
||||
<Link className="btn btn-info" to="/app/administrator/flags/incident-analysis">
|
||||
Incident Analysis
|
||||
</Link>
|
||||
<Link className="btn btn-secondary" to="/app/administrator/flags/processed">
|
||||
View Processed Incidents
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showAddForm ? (
|
||||
<div className="container my-4 border rounded p-3 bg-light">
|
||||
<h5 className="mb-3">New incident</h5>
|
||||
{gradesError ? <div className="alert alert-warning small">{gradesError}</div> : null}
|
||||
<form className="row g-3" onSubmit={(e) => void onAddSubmit(e)}>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="flags-grade">
|
||||
Grade
|
||||
</label>
|
||||
<select
|
||||
id="flags-grade"
|
||||
className="form-select"
|
||||
required
|
||||
value={gradeId}
|
||||
onChange={(e) => setGradeId(e.target.value)}
|
||||
>
|
||||
<option value="">Select Grade</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="flags-student">
|
||||
Student Name
|
||||
</label>
|
||||
<select
|
||||
id="flags-student"
|
||||
className="form-select"
|
||||
required
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
disabled={!gradeId || studentsLoading}
|
||||
>
|
||||
<option value="">{studentsLoading ? 'Loading…' : 'Select Student'}</option>
|
||||
{students.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="flags-incident">
|
||||
Incident
|
||||
</label>
|
||||
<select
|
||||
id="flags-incident"
|
||||
className="form-select"
|
||||
required
|
||||
value={incidentKey}
|
||||
onChange={(e) => onIncidentChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select Incident</option>
|
||||
<option value="payment">Payment</option>
|
||||
<option value="attendance">Attendance</option>
|
||||
<option value="grade">Grade</option>
|
||||
<option value="behavior">Behavior (General)</option>
|
||||
<optgroup label="Level 1 - Minor (Teacher-managed)">
|
||||
<option value="talking_off_task">Talking/off-task</option>
|
||||
<option value="calling_out">Calling out</option>
|
||||
<option value="minor_disruption">Minor disruption</option>
|
||||
<option value="minor_dress_code">Minor dress code</option>
|
||||
<option value="forgetting_materials">Forgetting materials</option>
|
||||
</optgroup>
|
||||
<optgroup label="Level 2 - Moderate / Repeated">
|
||||
<option value="repeated_defiance">Repeated defiance</option>
|
||||
<option value="disrespectful_tone">Disrespectful tone</option>
|
||||
<option value="minor_profanity">Minor profanity</option>
|
||||
<option value="repeated_tardiness">Repeated tardiness</option>
|
||||
<option value="device_misuse">Device misuse</option>
|
||||
<option value="minor_conflict">Minor conflict/horseplay</option>
|
||||
</optgroup>
|
||||
<optgroup label="Level 3 - Serious (Immediate admin)">
|
||||
<option value="bullying_harassment">Bullying/harassment</option>
|
||||
<option value="fighting_aggression">Fighting/aggression</option>
|
||||
<option value="threats">Threats</option>
|
||||
<option value="theft">Theft</option>
|
||||
<option value="vandalism">Vandalism</option>
|
||||
<option value="sexual_harassment">Sexual harassment</option>
|
||||
<option value="serious_cyber_incident">Serious cyber incident</option>
|
||||
<option value="contraband">Contraband</option>
|
||||
<option value="weapons_drugs">Weapons/drugs</option>
|
||||
</optgroup>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="flags-desc">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="flags-desc"
|
||||
name="description"
|
||||
className="form-control"
|
||||
rows={3}
|
||||
required
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-primary" disabled={addBusy}>
|
||||
{addBusy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary ms-2"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchIncidentAnalysis, type IncidentAnalysisStudent } from '../../api/flags'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
descriptionForLog,
|
||||
formatIncidentDatetime,
|
||||
incidentTypeLabel,
|
||||
} from './flagRowUtils'
|
||||
|
||||
/** CI `flags/incident_analysis.php` — per-student rollup + expandable logs. */
|
||||
export function IncidentAnalysisPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [students, setStudents] = useState<IncidentAnalysisStudent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchIncidentAnalysis(searchParams)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setStudents(data)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load incident analysis.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h2 className="text-center mt-4 mb-3">Incident Analysis</h2>
|
||||
<div className="d-flex gap-2 justify-content-end mb-3 flex-wrap">
|
||||
<Link className="btn btn-secondary" to="/app/administrator/flags/management">
|
||||
Back to Pending Incidents
|
||||
</Link>
|
||||
<Link className="btn btn-secondary" to="/app/administrator/flags/processed">
|
||||
View Processed Incidents
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AcademicFilterBar />
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student Name</th>
|
||||
<th>Grade</th>
|
||||
<th>Total Incidents</th>
|
||||
<th>Open</th>
|
||||
<th>Closed</th>
|
||||
<th>Canceled</th>
|
||||
<th>Last Incident</th>
|
||||
<th>Log & Follow-up</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && students.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>No incidents found.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{students.map((student, si) => (
|
||||
<tr key={`${student.student_name}-${student.grade}-${si}`}>
|
||||
<td>{student.student_name ?? ''}</td>
|
||||
<td>{student.grade ?? ''}</td>
|
||||
<td>{student.total ?? 0}</td>
|
||||
<td>{student.open ?? 0}</td>
|
||||
<td>{student.closed ?? 0}</td>
|
||||
<td>{student.canceled ?? 0}</td>
|
||||
<td>{formatIncidentDatetime(student.last_incident)}</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary>View log</summary>
|
||||
<div className="table-responsive mt-2">
|
||||
<table className="table table-sm table-bordered mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datetime</th>
|
||||
<th>Incident Type</th>
|
||||
<th>State</th>
|
||||
<th>Description</th>
|
||||
<th>Action Taken</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!student.logs?.length ? (
|
||||
<tr>
|
||||
<td colSpan={5}>No incident logs found.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{student.logs?.map((log, li) => (
|
||||
<tr key={li}>
|
||||
<td>{formatIncidentDatetime(log.flag_datetime)}</td>
|
||||
<td>{incidentTypeLabel(log.flag)}</td>
|
||||
<td>{log.flag_state ?? ''}</td>
|
||||
<td>{descriptionForLog(log)}</td>
|
||||
<td>{log.action_taken ?? 'N/A'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchFlagsProcessed, type IncidentFlagRow } from '../../api/flags'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
descriptionForFlag,
|
||||
formatIncidentDatetime,
|
||||
incidentTypeLabel,
|
||||
lastUpdaterCell,
|
||||
} from './flagRowUtils'
|
||||
|
||||
/** CI `flags/processed_flags.php` — processed / closed / canceled incidents. */
|
||||
export function ProcessedFlagsPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [rows, setRows] = useState<IncidentFlagRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchFlagsProcessed(searchParams)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setRows(data)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load processed incidents.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h2 className="text-center mt-4 mb-3">Processed Incidents</h2>
|
||||
<div className="d-flex justify-content-end mb-3">
|
||||
<Link className="btn btn-secondary" to="/app/administrator/flags/management">
|
||||
Back to Pending Incidents
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AcademicFilterBar />
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Last Updater</th>
|
||||
<th>Student Name</th>
|
||||
<th>Grade</th>
|
||||
<th>Incident Type</th>
|
||||
<th>Description</th>
|
||||
<th>Datetime</th>
|
||||
<th>Incident State</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>No processed incidents found.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((flag, i) => (
|
||||
<tr key={flag.id ?? i}>
|
||||
<td>{lastUpdaterCell(flag)}</td>
|
||||
<td>{flag.student_name ?? ''}</td>
|
||||
<td>{flag.grade ?? ''}</td>
|
||||
<td>{incidentTypeLabel(flag.flag)}</td>
|
||||
<td>{descriptionForFlag(flag)}</td>
|
||||
<td>{formatIncidentDatetime(flag.flag_datetime)}</td>
|
||||
<td>{flag.flag_state ?? ''}</td>
|
||||
<td>{flag.action_taken ?? 'N/A'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Autofill for “action taken” when closing (CI `flags_management.php`). */
|
||||
export const INCIDENT_ACTION_TEMPLATES: Record<string, string> = {
|
||||
payment:
|
||||
'Reminder/clarify expectations → parent contact if needed → admin follow-up if unresolved.',
|
||||
attendance:
|
||||
'Log + warning → lunch detention/supervised study → parent contact → admin conference + attendance plan.',
|
||||
grade:
|
||||
'Clarify expectations → support/reteach → parent contact if pattern → admin referral if unresolved.',
|
||||
behavior:
|
||||
'Start small → reteach expectations → repair harm → escalate only for repeat or safety issues.',
|
||||
talking_off_task:
|
||||
'Reminder → seat change/separation → reflection + reteach → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
calling_out:
|
||||
'Reminder → seat change → reflection + reteach → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
minor_disruption:
|
||||
'Reminder → seat change/separation → reflection + reteach → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
minor_dress_code:
|
||||
'Reminder/clarify → reflection + reteach → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
forgetting_materials:
|
||||
'Reminder + reteach routines → reflection → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
repeated_defiance:
|
||||
'Reflection + restorative chat → parent contact + lunch detention → behavior contract + counselor check-in → admin referral (ISS if needed).',
|
||||
disrespectful_tone:
|
||||
'Warning + rephrase → reflection + apology plan → parent contact + lunch detention → admin referral if repeated.',
|
||||
minor_profanity:
|
||||
'Warning + rephrase → reflection + apology plan → parent contact + lunch detention → admin referral if repeated.',
|
||||
repeated_tardiness:
|
||||
'Log + warning → lunch detention/supervised study → parent contact → admin conference + attendance plan.',
|
||||
device_misuse:
|
||||
'Warning → device away/confiscate (end of day) → loss of privilege + lunch detention/supervised study → admin referral if repeated.',
|
||||
minor_conflict:
|
||||
'Reflection + restorative chat → parent contact + lunch detention → behavior contract + counselor check-in → admin referral if repeated.',
|
||||
bullying_harassment:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
fighting_aggression:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
threats:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
theft:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
vandalism:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
sexual_harassment:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
serious_cyber_incident:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
contraband:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
weapons_drugs:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
other: '',
|
||||
}
|
||||
|
||||
/** Autofill for new incident description (CI `flags_management.php`). */
|
||||
export const INCIDENT_DESCRIPTIONS: Record<string, string> = {
|
||||
payment:
|
||||
'Payment-related concern, such as an overdue balance, missing tuition installment, or a fee that was not submitted on time.',
|
||||
attendance:
|
||||
'Attendance-related concern involving absences, patterns of late arrival, or missed instructional time affecting progress.',
|
||||
grade:
|
||||
'Grade-related concern where current performance is below expected level, missing work is affecting progress, or a trend needs attention.',
|
||||
behavior:
|
||||
'General behavior concern that does not fit a specific category but needs documentation for follow-up and support.',
|
||||
talking_off_task:
|
||||
'Student was talking or off-task during instruction or independent work, impacting focus or the learning environment.',
|
||||
calling_out: 'Student called out without permission or interrupted instruction, affecting classroom flow.',
|
||||
minor_disruption:
|
||||
'Student caused a minor disruption (noise, movement, or distractions) that interrupted learning briefly.',
|
||||
minor_dress_code:
|
||||
'Student was out of dress code in a minor way (e.g., small uniform issue) needing a reminder.',
|
||||
forgetting_materials:
|
||||
'Student did not bring required materials (books, supplies, device) needed for class activities.',
|
||||
repeated_defiance:
|
||||
'Student repeatedly refused to follow directions after being prompted, impacting class routines.',
|
||||
disrespectful_tone:
|
||||
'Student used a disrespectful tone or language toward staff or peers, requiring documentation.',
|
||||
minor_profanity:
|
||||
'Student used mild or inappropriate language that is not threatening but is still unacceptable.',
|
||||
repeated_tardiness:
|
||||
'Student has a pattern of arriving late to class or school, disrupting routines.',
|
||||
device_misuse:
|
||||
'Student used a phone or device inappropriately (off-task apps, messaging, or during instruction).',
|
||||
minor_conflict:
|
||||
'Student engaged in minor conflict or horseplay that could escalate if not addressed.',
|
||||
bullying_harassment:
|
||||
'Bullying or harassment behavior reported, including repeated targeted actions or intimidation.',
|
||||
fighting_aggression: 'Physical fighting or aggressive contact occurred or was attempted.',
|
||||
threats: 'Verbal or written threats made toward a person or group that raise safety concerns.',
|
||||
theft: 'Theft or attempted theft of property was reported.',
|
||||
vandalism: 'Intentional damage to property or school materials was reported.',
|
||||
sexual_harassment:
|
||||
'Sexual harassment reported, including inappropriate comments, gestures, or contact.',
|
||||
serious_cyber_incident:
|
||||
'Serious online incident reported (harassment, threats, or harmful digital behavior).',
|
||||
contraband: 'Possession of unauthorized or prohibited items on campus.',
|
||||
weapons_drugs: 'Possession or involvement with weapons or drugs reported.',
|
||||
other: '',
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { incidentLabel } from './incidentLabels'
|
||||
import type { IncidentFlagRow, IncidentLogRow } from '../../api/flags'
|
||||
|
||||
export function formatIncidentDatetime(iso?: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return String(iso)
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const yyyy = d.getFullYear()
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd}-${yyyy} ${hh}:${min}`
|
||||
}
|
||||
|
||||
export function lastUpdaterCell(flag: IncidentFlagRow): string {
|
||||
const st = flag.flag_state
|
||||
if (st === 'Open') {
|
||||
return String(flag.updated_by_open_name ?? flag.updated_by_open ?? 'N/A')
|
||||
}
|
||||
if (st === 'Closed') {
|
||||
return String(flag.updated_by_closed_name ?? flag.updated_by_closed ?? 'N/A')
|
||||
}
|
||||
if (st === 'Canceled') {
|
||||
return String(flag.updated_by_canceled_name ?? flag.updated_by_canceled ?? 'N/A')
|
||||
}
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
export function descriptionForFlag(flag: IncidentFlagRow): string {
|
||||
const st = flag.flag_state
|
||||
if (st === 'Open') return flag.open_description ?? 'N/A'
|
||||
if (st === 'Closed') return flag.close_description ?? 'N/A'
|
||||
if (st === 'Canceled') return flag.cancel_description ?? 'N/A'
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
export function descriptionForLog(log: IncidentLogRow): string {
|
||||
const st = log.flag_state
|
||||
if (st === 'Open' && log.open_description) return String(log.open_description)
|
||||
if (st === 'Closed' && log.close_description) return String(log.close_description)
|
||||
if (st === 'Canceled' && log.cancel_description) return String(log.cancel_description)
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
export function incidentTypeLabel(flagKey: string | undefined): string {
|
||||
return incidentLabel(flagKey)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Maps stored `flag` keys to display labels (parity with CI `flags/*.php`). */
|
||||
export const INCIDENT_LABELS: Record<string, string> = {
|
||||
payment: 'Payment',
|
||||
attendance: 'Attendance',
|
||||
grade: 'Grade',
|
||||
behavior: 'Behavior (General)',
|
||||
talking_off_task: 'Talking/off-task',
|
||||
calling_out: 'Calling out',
|
||||
minor_disruption: 'Minor disruption',
|
||||
minor_dress_code: 'Minor dress code',
|
||||
forgetting_materials: 'Forgetting materials',
|
||||
repeated_defiance: 'Repeated defiance',
|
||||
disrespectful_tone: 'Disrespectful tone',
|
||||
minor_profanity: 'Minor profanity',
|
||||
repeated_tardiness: 'Repeated tardiness',
|
||||
device_misuse: 'Device misuse',
|
||||
minor_conflict: 'Minor conflict/horseplay',
|
||||
bullying_harassment: 'Bullying/harassment',
|
||||
fighting_aggression: 'Fighting/aggression',
|
||||
threats: 'Threats',
|
||||
theft: 'Theft',
|
||||
vandalism: 'Vandalism',
|
||||
sexual_harassment: 'Sexual harassment',
|
||||
serious_cyber_incident: 'Serious cyber incident',
|
||||
contraband: 'Contraband',
|
||||
weapons_drugs: 'Weapons/drugs',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
export function incidentLabel(flagKey: string | null | undefined): string {
|
||||
const v = (flagKey ?? '').trim()
|
||||
if (!v) return 'N/A'
|
||||
return INCIDENT_LABELS[v] ?? v
|
||||
}
|
||||
|
||||
export function normalizeIncidentKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[()]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchBelowSixtyEmailEditor, postBelowSixtyEmail } from '../../api/grading'
|
||||
import { GRADING_BELOW_SIXTY_PATH } from './gradingPaths'
|
||||
|
||||
/** CI `grading/below_sixty_email_editor.php` — rich email replaced with HTML textarea until an editor is added. */
|
||||
export function BelowSixtyEmailEditorPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const studentId = searchParams.get('student_id') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
|
||||
const [html, setHtml] = useState('')
|
||||
const [subjectLine, setSubjectLine] = useState('')
|
||||
const [studentName, setStudentName] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!studentId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchBelowSixtyEmailEditor(searchParams)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
setStudentName(p.studentName ?? '')
|
||||
setSubjectLine(p.subject ?? '')
|
||||
setHtml(p.html ?? '')
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load email editor.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [searchParams, studentId])
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
await postBelowSixtyEmail({
|
||||
student_id: Number(studentId),
|
||||
semester,
|
||||
school_year: schoolYear,
|
||||
subject: subjectLine || undefined,
|
||||
html,
|
||||
})
|
||||
setSuccess('Email sent.')
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Send failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const backQs = searchParams.toString()
|
||||
|
||||
if (!studentId) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-info">
|
||||
Missing <code>student_id</code> in the URL.
|
||||
</div>
|
||||
<Link className="btn btn-secondary" to={`${GRADING_BELOW_SIXTY_PATH}${backQs ? `?${backQs}` : ''}`}>
|
||||
Back to Below 60
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h2 className="mb-0">Email parent — below 60</h2>
|
||||
<Link
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
to={`${GRADING_BELOW_SIXTY_PATH}${backQs ? `?${backQs}` : ''}`}
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{studentName ? <p className="text-muted mb-2">Student: {studentName}</p> : null}
|
||||
{semester || schoolYear ? (
|
||||
<p className="small text-muted mb-3">
|
||||
{semester} {schoolYear}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{success ? <div className="alert alert-success">{success}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading ? (
|
||||
<form onSubmit={(ev) => void onSubmit(ev)}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={subjectLine}
|
||||
onChange={(e) => setSubjectLine(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Message (HTML)</label>
|
||||
<textarea
|
||||
className="form-control font-monospace"
|
||||
rows={18}
|
||||
value={html}
|
||||
onChange={(e) => setHtml(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Sending…' : 'Send email'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchBelowSixty, postBelowSixtyStatus } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH,
|
||||
GRADING_PATH,
|
||||
GRADING_SCHEDULE_MEETING_PATH,
|
||||
} from './gradingPaths'
|
||||
|
||||
type BelowRow = Record<string, unknown>
|
||||
|
||||
function displayScore(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value.toFixed(2)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** CI `grading/below_sixty.php` */
|
||||
export function BelowSixtyPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const qs = searchParams.toString()
|
||||
|
||||
const [rows, setRows] = useState<BelowRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
fetchBelowSixty(searchParams)
|
||||
.then((data) => {
|
||||
const raw =
|
||||
data && typeof data === 'object' && 'rows' in data && Array.isArray((data as { rows: unknown }).rows)
|
||||
? (data as { rows: BelowRow[] }).rows
|
||||
: Array.isArray(data)
|
||||
? (data as BelowRow[])
|
||||
: []
|
||||
setRows(raw)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load below-60 summary.')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [searchParams])
|
||||
|
||||
const semLabel =
|
||||
semester.toLowerCase() === 'fall' ? '1st Semester Score' : 'Semester Score'
|
||||
|
||||
async function onStatusSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
try {
|
||||
await postBelowSixtyStatus({
|
||||
student_id: fd.get('student_id'),
|
||||
semester: fd.get('semester'),
|
||||
school_year: fd.get('school_year'),
|
||||
status: fd.get('status'),
|
||||
note: fd.get('note'),
|
||||
})
|
||||
load()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Status update failed.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h2 className="text-center mt-4 mb-4">Below 60 Summary</h2>
|
||||
<AcademicFilterBar />
|
||||
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div className="text-muted">
|
||||
{semester ? `${semester} • ` : ''}
|
||||
{schoolYear}
|
||||
</div>
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
|
||||
Back to Grading
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading && rows.length === 0 ? (
|
||||
<div className="alert alert-success text-center d-inline-block">
|
||||
No students below 60 for this selection.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && rows.length > 0 ? (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-striped align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Student Name</th>
|
||||
<th>Section</th>
|
||||
<th>Hwk Avg</th>
|
||||
<th>Project Avg</th>
|
||||
<th>Participation</th>
|
||||
<th>Test Avg</th>
|
||||
<th>PTAP Score</th>
|
||||
<th>Attendance</th>
|
||||
<th>Midterm Score</th>
|
||||
<th>{semLabel}</th>
|
||||
<th>Status</th>
|
||||
<th>Email Parent</th>
|
||||
<th>Schedule Meeting</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const sid = Number(row.student_id ?? 0)
|
||||
const first = String(row.firstname ?? '')
|
||||
const last = String(row.lastname ?? '')
|
||||
const studentName = `${first} ${last}`.trim() || 'N/A'
|
||||
const scoreRaw = row.semester_score
|
||||
const scoreVal =
|
||||
typeof scoreRaw === 'number'
|
||||
? scoreRaw
|
||||
: typeof scoreRaw === 'string' && scoreRaw !== ''
|
||||
? Number(scoreRaw)
|
||||
: null
|
||||
let rowClass = ''
|
||||
if (scoreVal !== null && Number.isFinite(scoreVal)) {
|
||||
if (scoreVal < 50) rowClass = 'table-danger'
|
||||
else if (scoreVal < 60) rowClass = 'table-warning'
|
||||
}
|
||||
const isClosed = String(row.status ?? 'Open') === 'Closed'
|
||||
const emailTo = `${GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH}?student_id=${sid}&semester=${encodeURIComponent(semester)}&school_year=${encodeURIComponent(schoolYear)}`
|
||||
const schedTo = `${GRADING_SCHEDULE_MEETING_PATH}?student_id=${sid}&semester=${encodeURIComponent(semester)}&school_year=${encodeURIComponent(schoolYear)}`
|
||||
|
||||
return (
|
||||
<tr key={sid || i} className={rowClass}>
|
||||
<td>{studentName}</td>
|
||||
<td>{String(row.class_section_name ?? '—')}</td>
|
||||
<td className="text-center">{displayScore(row.homework_avg)}</td>
|
||||
<td className="text-center">{displayScore(row.project_avg)}</td>
|
||||
<td className="text-center">{displayScore(row.participation_score)}</td>
|
||||
<td className="text-center">{displayScore(row.test_avg)}</td>
|
||||
<td className="text-center">{displayScore(row.ptap_score)}</td>
|
||||
<td className="text-center">{displayScore(row.attendance_score)}</td>
|
||||
<td className="text-center">{displayScore(row.midterm_exam_score)}</td>
|
||||
<td className="text-center">{displayScore(row.semester_score)}</td>
|
||||
<td className="text-center">
|
||||
<form
|
||||
className="d-flex align-items-center gap-2 justify-content-center flex-wrap"
|
||||
onSubmit={(ev) => void onStatusSubmit(ev)}
|
||||
>
|
||||
<input type="hidden" name="student_id" value={sid} />
|
||||
<input type="hidden" name="semester" value={semester} />
|
||||
<input type="hidden" name="school_year" value={schoolYear} />
|
||||
<select
|
||||
name="status"
|
||||
className="form-select form-select-sm"
|
||||
style={{ width: 110 }}
|
||||
defaultValue={String(row.status ?? 'Open')}
|
||||
>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="note"
|
||||
className="form-control form-control-sm"
|
||||
style={{ width: 140 }}
|
||||
placeholder="Note"
|
||||
defaultValue={String(row.note ?? '')}
|
||||
/>
|
||||
<button type="submit" className="btn btn-sm btn-outline-secondary">
|
||||
Update
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{isClosed ? (
|
||||
<button type="button" className="btn btn-sm btn-secondary" disabled>
|
||||
Send Email
|
||||
</button>
|
||||
) : (
|
||||
<Link className="btn btn-sm btn-outline-primary" to={emailTo}>
|
||||
Send Email
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{isClosed ? (
|
||||
<button type="button" className="btn btn-sm btn-secondary" disabled>
|
||||
Schedule
|
||||
</button>
|
||||
) : (
|
||||
<Link className="btn btn-sm btn-outline-secondary" to={schedTo}>
|
||||
Schedule
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchGradingMain } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
GRADING_BELOW_SIXTY_PATH,
|
||||
GRADING_HOMEWORK_TRACKING_PATH,
|
||||
GRADING_PARTICIPATION_PATH,
|
||||
GRADING_PLACEMENT_INDEX_PATH,
|
||||
gradingScoresPath,
|
||||
} from './gradingPaths'
|
||||
|
||||
/** CI `grading/grading_main.php` — hub + filters; detail tabs come from API payload when available. */
|
||||
export function GradingMainPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [payload, setPayload] = useState<unknown>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchGradingMain(searchParams)
|
||||
.then((p) => {
|
||||
if (!c) {
|
||||
setPayload(p)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load grading hub.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const q = searchParams.toString()
|
||||
const qs = q ? `?${q}` : ''
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="text-center mt-4 mb-3">Grading Management</h2>
|
||||
|
||||
<AcademicFilterBar />
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">Quick links</div>
|
||||
<div className="card-body row g-2">
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<Link className="btn btn-outline-primary w-100" to={`${GRADING_BELOW_SIXTY_PATH}${qs}`}>
|
||||
Below 60 Summary
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<Link
|
||||
className="btn btn-outline-primary w-100"
|
||||
to={`${GRADING_HOMEWORK_TRACKING_PATH}${qs}`}
|
||||
>
|
||||
Homework Tracking
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<Link className="btn btn-outline-primary w-100" to={`${GRADING_PARTICIPATION_PATH}${qs}`}>
|
||||
Participation Scores
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<Link className="btn btn-outline-primary w-100" to={`${GRADING_PLACEMENT_INDEX_PATH}${qs}`}>
|
||||
Placement Scores
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<Link className="btn btn-outline-secondary w-100" to={`${gradingScoresPath('homework')}${qs}`}>
|
||||
Example: Homework scores (needs student_id & class_section_id)
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{payload != null && !loading ? (
|
||||
<details className="mb-4">
|
||||
<summary className="small text-muted">Raw API payload (tabs/sections render here when wired)</summary>
|
||||
<pre className="small bg-light p-3 rounded mt-2 overflow-auto" style={{ maxHeight: 420 }}>
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
fetchGradingScoreForm,
|
||||
postGradingScoreUpdate,
|
||||
type GradingScoreFormPayload,
|
||||
type GradingScoreRow,
|
||||
} from '../../api/grading'
|
||||
import { isGradingScoreType } from './gradingScoreTypes'
|
||||
import { GRADING_PATH } from './gradingPaths'
|
||||
|
||||
/** CI `grading/test.php`, `homework.php`, `quiz.php`, `project.php`, `midterm.php`, `final.php`, `comments.php`. */
|
||||
export function GradingScoreEntryPage() {
|
||||
const { scoreType = '' } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const studentId = searchParams.get('student_id') ?? ''
|
||||
const classSectionId = searchParams.get('class_section_id') ?? ''
|
||||
|
||||
const [data, setData] = useState<GradingScoreFormPayload | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [scoreRows, setScoreRows] = useState<GradingScoreRow[]>([])
|
||||
const [singleScore, setSingleScore] = useState('')
|
||||
const [generalComment, setGeneralComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGradingScoreType(scoreType) || !studentId || !classSectionId) {
|
||||
setLoading(false)
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchGradingScoreForm(scoreType, studentId, classSectionId)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
setData(p)
|
||||
setScoreRows(Array.isArray(p.scores) ? [...p.scores] : [])
|
||||
const first = p.scores?.[0]
|
||||
setSingleScore(
|
||||
first?.score !== undefined && first?.score !== null ? String(first.score) : '',
|
||||
)
|
||||
setGeneralComment(first?.comment != null ? String(first.comment) : '')
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load score form.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [scoreType, studentId, classSectionId])
|
||||
|
||||
const title =
|
||||
scoreType === 'homework'
|
||||
? 'Homework'
|
||||
: scoreType === 'quiz'
|
||||
? 'Quiz'
|
||||
: scoreType === 'project'
|
||||
? 'Project'
|
||||
: scoreType === 'test'
|
||||
? 'Test'
|
||||
: scoreType === 'midterm'
|
||||
? 'Midterm Exam'
|
||||
: scoreType === 'final'
|
||||
? 'Final Exam'
|
||||
: scoreType === 'comments'
|
||||
? 'General Comments'
|
||||
: 'Scores'
|
||||
|
||||
const locked = Boolean(data?.scoresLocked)
|
||||
const student = data?.student
|
||||
const name =
|
||||
student?.firstname || student?.lastname
|
||||
? `${student?.firstname ?? ''} ${student?.lastname ?? ''}`.trim()
|
||||
: 'Student'
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!isGradingScoreType(scoreType) || !studentId || !classSectionId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
let body: Record<string, unknown> = {
|
||||
type: scoreType,
|
||||
student_id: Number(studentId),
|
||||
class_section_id: Number(classSectionId),
|
||||
}
|
||||
if (['homework', 'quiz', 'project'].includes(scoreType)) {
|
||||
body = {
|
||||
...body,
|
||||
score_ids: scoreRows.map((r) => r.id),
|
||||
scores: scoreRows.map((r) => r.score),
|
||||
comments: scoreRows.map((r) => r.comment ?? ''),
|
||||
}
|
||||
} else if (['midterm', 'final', 'test'].includes(scoreType)) {
|
||||
body = { ...body, score: singleScore === '' ? null : Number(singleScore) }
|
||||
} else if (scoreType === 'comments') {
|
||||
body = { ...body, comment: generalComment }
|
||||
}
|
||||
await postGradingScoreUpdate(scoreType, body)
|
||||
const next = await fetchGradingScoreForm(scoreType, studentId, classSectionId)
|
||||
setData(next)
|
||||
setScoreRows(Array.isArray(next.scores) ? [...next.scores] : [])
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isGradingScoreType(scoreType)) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-warning">Unknown score type.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!studentId || !classSectionId) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-info">
|
||||
Add <code>student_id</code> and <code>class_section_id</code> query parameters (open from
|
||||
Grading Management).
|
||||
</div>
|
||||
<Link className="btn btn-secondary" to={GRADING_PATH}>
|
||||
Return to Grading Management
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<h2 className="text-center mb-4">
|
||||
{title} Scores for <span className="text-decoration-none">{name}</span>
|
||||
</h2>
|
||||
{locked ? (
|
||||
<div className="alert alert-warning text-center">
|
||||
Scores are locked for this class. Unlock to edit.
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading && data ? (
|
||||
<form onSubmit={(e) => void onSubmit(e)}>
|
||||
{['homework', 'quiz', 'project'].includes(scoreType) ? (
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Score</th>
|
||||
<th>Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreRows.map((row, index) => (
|
||||
<tr key={row.id ?? index}>
|
||||
<td>{index + 1}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
disabled={locked}
|
||||
value={row.score ?? ''}
|
||||
onChange={(ev) => {
|
||||
const next = [...scoreRows]
|
||||
next[index] = { ...next[index], score: ev.target.value }
|
||||
setScoreRows(next)
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<textarea
|
||||
className="form-control"
|
||||
disabled={locked}
|
||||
value={row.comment ?? ''}
|
||||
onChange={(ev) => {
|
||||
const next = [...scoreRows]
|
||||
next[index] = { ...next[index], comment: ev.target.value }
|
||||
setScoreRows(next)
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
|
||||
{['midterm', 'final', 'test'].includes(scoreType) ? (
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Score</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
disabled={locked}
|
||||
value={singleScore}
|
||||
onChange={(e) => setSingleScore(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scoreType === 'comments' ? (
|
||||
<div className="mb-3">
|
||||
<label className="form-label">General Comment</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={5}
|
||||
disabled={locked}
|
||||
value={generalComment}
|
||||
onChange={(e) => setGeneralComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-center mt-3">
|
||||
<button type="submit" className="btn btn-primary" disabled={locked || saving}>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<Link className="btn btn-secondary" to={GRADING_PATH}>
|
||||
Return to Main Page
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchHomeworkTracking } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import { trackingSectionsFromPayload } from './gradingPayloadHelpers'
|
||||
import { GRADING_PATH } from './gradingPaths'
|
||||
|
||||
function cell(v: unknown): string {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
return String(v)
|
||||
}
|
||||
|
||||
/** CI `grading/homework_tracking.php` */
|
||||
export function HomeworkTrackingPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [payload, setPayload] = useState<unknown>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchHomeworkTracking(searchParams)
|
||||
.then((p) => {
|
||||
if (!c) {
|
||||
setPayload(p)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load homework tracking.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const sections = trackingSectionsFromPayload(payload)
|
||||
const qs = searchParams.toString()
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="text-center mt-2 mb-3">Homework tracking</h2>
|
||||
<AcademicFilterBar />
|
||||
|
||||
<div className="mb-3">
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
|
||||
Grading hub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading && sections.length === 0 ? (
|
||||
<div className="alert alert-light border">No rows returned for this filter.</div>
|
||||
) : null}
|
||||
|
||||
{!loading &&
|
||||
sections.map((sec) => {
|
||||
const columns =
|
||||
sec.rows[0] && typeof sec.rows[0] === 'object'
|
||||
? Object.keys(sec.rows[0] as object)
|
||||
: ['student_id', 'name', 'score', 'comment']
|
||||
return (
|
||||
<div key={sec.title} className="mb-5">
|
||||
<h5 className="border-bottom pb-2">{sec.title}</h5>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col}>{col.replace(/_/g, ' ')}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sec.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{columns.map((col) => (
|
||||
<td key={col}>{cell(row[col])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{payload != null && !loading ? (
|
||||
<details className="mt-4">
|
||||
<summary className="small text-muted">Raw payload</summary>
|
||||
<pre className="small bg-light p-3 rounded mt-2 overflow-auto" style={{ maxHeight: 320 }}>
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchParticipation, postParticipation } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import { rowsFromUnknown } from './gradingPayloadHelpers'
|
||||
import { GRADING_PATH } from './gradingPaths'
|
||||
|
||||
/** CI `grading/participation.php` */
|
||||
export function ParticipationPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [rows, setRows] = useState<Record<string, unknown>[]>([])
|
||||
const [locked, setLocked] = useState(false)
|
||||
const [title, setTitle] = useState('Participation scores')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchParticipation(searchParams)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
setRows(rowsFromUnknown(p))
|
||||
if (p && typeof p === 'object') {
|
||||
const o = p as Record<string, unknown>
|
||||
setLocked(Boolean(o.scoresLocked ?? o.locked))
|
||||
if (typeof o.title === 'string') setTitle(o.title)
|
||||
}
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load participation.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const scoreKey = (row: Record<string, unknown>): string => {
|
||||
if ('participation_score' in row) return 'participation_score'
|
||||
if ('score' in row) return 'score'
|
||||
return 'participation_score'
|
||||
}
|
||||
|
||||
function setScore(i: number, value: string) {
|
||||
setRows((prev) => {
|
||||
const next = [...prev]
|
||||
const k = scoreKey(next[i] ?? {})
|
||||
next[i] = { ...next[i], [k]: value === '' ? null : Number(value) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
rows,
|
||||
school_year: searchParams.get('school_year') ?? undefined,
|
||||
semester: searchParams.get('semester') ?? undefined,
|
||||
}
|
||||
await postParticipation(body)
|
||||
const next = await fetchParticipation(searchParams)
|
||||
setRows(rowsFromUnknown(next))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const qs = searchParams.toString()
|
||||
const cols = rows[0] ? Object.keys(rows[0]) : ['student_id', 'firstname', 'lastname', 'participation_score']
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="text-center mt-2 mb-3">{title}</h2>
|
||||
<AcademicFilterBar />
|
||||
|
||||
<div className="mb-3">
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
|
||||
Grading hub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{locked ? (
|
||||
<div className="alert alert-warning">Scores are locked for this selection.</div>
|
||||
) : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading && rows.length > 0 ? (
|
||||
<form onSubmit={(ev) => void onSubmit(ev)}>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
{cols.map((c) => (
|
||||
<th key={c}>{c.replace(/_/g, ' ')}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const sk = scoreKey(row)
|
||||
return (
|
||||
<tr key={i}>
|
||||
{cols.map((col) =>
|
||||
col === sk && !locked ? (
|
||||
<td key={col}>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm"
|
||||
style={{ width: 96 }}
|
||||
value={row[col] === null || row[col] === undefined ? '' : String(row[col])}
|
||||
onChange={(ev) => setScore(i, ev.target.value)}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<td key={col}>{row[col] === null || row[col] === undefined ? '—' : String(row[col])}</td>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-center mt-3">
|
||||
<button type="submit" className="btn btn-primary" disabled={locked || saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{!loading && rows.length === 0 ? (
|
||||
<div className="alert alert-light border">No students for this filter.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchPlacementBatch, postPlacementBatch } from '../../api/grading'
|
||||
import { rowsFromUnknown } from './gradingPayloadHelpers'
|
||||
import { GRADING_PLACEMENT_INDEX_PATH } from './gradingPaths'
|
||||
|
||||
/** CI `grading/placement_batch.php` */
|
||||
export function PlacementBatchPage() {
|
||||
const { batchId = '' } = useParams()
|
||||
const [payload, setPayload] = useState<unknown>(null)
|
||||
const [rows, setRows] = useState<Record<string, unknown>[]>([])
|
||||
const [locked, setLocked] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchPlacementBatch(batchId)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
setPayload(p)
|
||||
setRows(rowsFromUnknown(p))
|
||||
if (p && typeof p === 'object') {
|
||||
const o = p as Record<string, unknown>
|
||||
setLocked(Boolean(o.scoresLocked ?? o.locked))
|
||||
}
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load batch.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [batchId])
|
||||
|
||||
const scoreKey = (row: Record<string, unknown>): string => {
|
||||
if ('placement_score' in row) return 'placement_score'
|
||||
if ('score' in row) return 'score'
|
||||
return 'placement_score'
|
||||
}
|
||||
|
||||
function setScore(i: number, value: string) {
|
||||
setRows((prev) => {
|
||||
const next = [...prev]
|
||||
const k = scoreKey(next[i] ?? {})
|
||||
next[i] = { ...next[i], [k]: value === '' ? null : Number(value) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await postPlacementBatch(batchId, { rows })
|
||||
const next = await fetchPlacementBatch(batchId)
|
||||
setPayload(next)
|
||||
setRows(rowsFromUnknown(next))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cols = rows[0] ? Object.keys(rows[0]) : ['student_id', 'placement_score']
|
||||
|
||||
if (!batchId) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-warning">Missing batch id in the URL.</div>
|
||||
<Link className="btn btn-secondary" to={GRADING_PLACEMENT_INDEX_PATH}>
|
||||
Placement index
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="text-center mt-2 mb-3">Placement batch</h2>
|
||||
<p className="text-center text-muted small">Batch #{batchId}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={GRADING_PLACEMENT_INDEX_PATH}>
|
||||
Placement index
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{locked ? <div className="alert alert-warning">Locked.</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading && rows.length > 0 ? (
|
||||
<form onSubmit={(ev) => void onSubmit(ev)}>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
{cols.map((c) => (
|
||||
<th key={c}>{c.replace(/_/g, ' ')}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const sk = scoreKey(row)
|
||||
return (
|
||||
<tr key={i}>
|
||||
{cols.map((col) =>
|
||||
col === sk && !locked ? (
|
||||
<td key={col}>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm"
|
||||
style={{ width: 96 }}
|
||||
value={row[col] === null || row[col] === undefined ? '' : String(row[col])}
|
||||
onChange={(ev) => setScore(i, ev.target.value)}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<td key={col}>{row[col] === null || row[col] === undefined ? '—' : String(row[col])}</td>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-center mt-3">
|
||||
<button type="submit" className="btn btn-primary" disabled={locked || saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{!loading && rows.length === 0 ? (
|
||||
<div className="alert alert-light border">No rows in this batch.</div>
|
||||
) : null}
|
||||
|
||||
{payload != null && !loading ? (
|
||||
<details className="mt-4">
|
||||
<summary className="small text-muted">Raw payload</summary>
|
||||
<pre className="small bg-light p-3 rounded mt-2 overflow-auto" style={{ maxHeight: 260 }}>
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchPlacementIndex, postPlacementAll } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import { sectionsFromUnknown } from './gradingPayloadHelpers'
|
||||
import { GRADING_PATH, GRADING_PLACEMENT_PATH } from './gradingPaths'
|
||||
|
||||
function sectionId(row: Record<string, unknown>): string {
|
||||
const id = row.class_section_id ?? row.id ?? row.section_id
|
||||
return id !== undefined && id !== null ? String(id) : ''
|
||||
}
|
||||
|
||||
function sectionLabel(row: Record<string, unknown>): string {
|
||||
return String(
|
||||
row.class_section_name ?? row.name ?? row.title ?? row.section_name ?? row.class_section_id ?? 'Section',
|
||||
)
|
||||
}
|
||||
|
||||
/** CI `grading/placement_index.php` */
|
||||
export function PlacementIndexPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [sections, setSections] = useState<Record<string, unknown>[]>([])
|
||||
const [payload, setPayload] = useState<unknown>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchPlacementIndex(searchParams)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
setPayload(p)
|
||||
setSections(sectionsFromUnknown(p))
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load placement index.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
async function runPlacementAll(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await postPlacementAll({
|
||||
school_year: searchParams.get('school_year') ?? undefined,
|
||||
semester: searchParams.get('semester') ?? undefined,
|
||||
})
|
||||
const next = await fetchPlacementIndex(searchParams)
|
||||
setPayload(next)
|
||||
setSections(sectionsFromUnknown(next))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Action failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const baseQs = searchParams.toString()
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="text-center mt-2 mb-3">Placement scores — sections</h2>
|
||||
<AcademicFilterBar />
|
||||
|
||||
<div className="d-flex flex-wrap gap-2 mb-3">
|
||||
<Link
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
to={`${GRADING_PATH}${baseQs ? `?${baseQs}` : ''}`}
|
||||
>
|
||||
Grading hub
|
||||
</Link>
|
||||
<form onSubmit={(ev) => void runPlacementAll(ev)} className="d-inline">
|
||||
<button type="submit" className="btn btn-outline-primary btn-sm" disabled={busy}>
|
||||
{busy ? 'Working…' : 'Run placement for all (API)'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading && sections.length === 0 ? (
|
||||
<div className="alert alert-light border">No sections in the response.</div>
|
||||
) : null}
|
||||
|
||||
<ul className="list-group">
|
||||
{!loading &&
|
||||
sections.map((row, i) => {
|
||||
const id = sectionId(row)
|
||||
const href = id
|
||||
? `${GRADING_PLACEMENT_PATH}?class_section_id=${encodeURIComponent(id)}${baseQs ? `&${baseQs}` : ''}`
|
||||
: '#'
|
||||
return (
|
||||
<li key={id || i} className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>{sectionLabel(row)}</span>
|
||||
{id ? (
|
||||
<Link className="btn btn-sm btn-primary" to={href}>
|
||||
Open
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted small">Missing section id</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{payload != null && !loading ? (
|
||||
<details className="mt-4">
|
||||
<summary className="small text-muted">Raw payload</summary>
|
||||
<pre className="small bg-light p-3 rounded mt-2 overflow-auto" style={{ maxHeight: 280 }}>
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchPlacementSection, postPlacementSection } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import { rowsFromUnknown } from './gradingPayloadHelpers'
|
||||
import { GRADING_PLACEMENT_INDEX_PATH } from './gradingPaths'
|
||||
|
||||
/** CI `grading/placement.php` — per-section placement scores. */
|
||||
export function PlacementSectionPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const classSectionId = searchParams.get('class_section_id') ?? ''
|
||||
|
||||
const [rows, setRows] = useState<Record<string, unknown>[]>([])
|
||||
const [locked, setLocked] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!classSectionId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchPlacementSection(searchParams)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
setRows(rowsFromUnknown(p))
|
||||
if (p && typeof p === 'object') {
|
||||
const o = p as Record<string, unknown>
|
||||
setLocked(Boolean(o.scoresLocked ?? o.locked))
|
||||
}
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load placement.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [searchParams, classSectionId])
|
||||
|
||||
const scoreKey = (row: Record<string, unknown>): string => {
|
||||
if ('placement_score' in row) return 'placement_score'
|
||||
if ('score' in row) return 'score'
|
||||
return 'placement_score'
|
||||
}
|
||||
|
||||
function setScore(i: number, value: string) {
|
||||
setRows((prev) => {
|
||||
const next = [...prev]
|
||||
const k = scoreKey(next[i] ?? {})
|
||||
next[i] = { ...next[i], [k]: value === '' ? null : Number(value) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await postPlacementSection({
|
||||
class_section_id: Number(classSectionId),
|
||||
rows,
|
||||
school_year: searchParams.get('school_year') ?? undefined,
|
||||
semester: searchParams.get('semester') ?? undefined,
|
||||
})
|
||||
const next = await fetchPlacementSection(searchParams)
|
||||
setRows(rowsFromUnknown(next))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const qs = searchParams.toString()
|
||||
const cols = rows[0] ? Object.keys(rows[0]) : ['student_id', 'firstname', 'lastname', 'placement_score']
|
||||
|
||||
if (!classSectionId) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-info">
|
||||
Add <code>class_section_id</code> to the URL (open from Placement index).
|
||||
</div>
|
||||
<Link className="btn btn-secondary" to={GRADING_PLACEMENT_INDEX_PATH}>
|
||||
Placement index
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="text-center mt-2 mb-3">Placement scores</h2>
|
||||
<p className="text-center text-muted small">Section #{classSectionId}</p>
|
||||
<AcademicFilterBar />
|
||||
|
||||
<div className="mb-3">
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PLACEMENT_INDEX_PATH}${qs ? `?${qs}` : ''}`}>
|
||||
All sections
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{locked ? <div className="alert alert-warning">Scores are locked.</div> : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading && rows.length > 0 ? (
|
||||
<form onSubmit={(ev) => void onSubmit(ev)}>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
{cols.map((c) => (
|
||||
<th key={c}>{c.replace(/_/g, ' ')}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const sk = scoreKey(row)
|
||||
return (
|
||||
<tr key={i}>
|
||||
{cols.map((col) =>
|
||||
col === sk && !locked ? (
|
||||
<td key={col}>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm"
|
||||
style={{ width: 96 }}
|
||||
value={row[col] === null || row[col] === undefined ? '' : String(row[col])}
|
||||
onChange={(ev) => setScore(i, ev.target.value)}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<td key={col}>{row[col] === null || row[col] === undefined ? '—' : String(row[col])}</td>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-center mt-3">
|
||||
<button type="submit" className="btn btn-primary" disabled={locked || saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{!loading && rows.length === 0 ? (
|
||||
<div className="alert alert-light border">No rows for this section.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchScheduleMeetingForm, postScheduleMeeting } from '../../api/grading'
|
||||
import { GRADING_BELOW_SIXTY_PATH } from './gradingPaths'
|
||||
|
||||
/** CI `grading/schedule_meeting.php` */
|
||||
export function ScheduleMeetingPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const studentId = searchParams.get('student_id') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
|
||||
const [studentName, setStudentName] = useState('')
|
||||
const [meetingAt, setMeetingAt] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [done, setDone] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!studentId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchScheduleMeetingForm(searchParams)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
if (p && typeof p === 'object') {
|
||||
const o = p as Record<string, unknown>
|
||||
setStudentName(String(o.studentName ?? o.student_name ?? ''))
|
||||
setMeetingAt(String(o.meeting_at ?? o.meetingAt ?? ''))
|
||||
setNotes(String(o.notes ?? o.note ?? ''))
|
||||
}
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load form.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [searchParams, studentId])
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setDone(null)
|
||||
try {
|
||||
await postScheduleMeeting({
|
||||
student_id: Number(studentId),
|
||||
semester,
|
||||
school_year: schoolYear,
|
||||
meeting_at: meetingAt || undefined,
|
||||
notes,
|
||||
})
|
||||
setDone('Saved.')
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const backQs = searchParams.toString()
|
||||
|
||||
if (!studentId) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-info">
|
||||
Missing <code>student_id</code>.
|
||||
</div>
|
||||
<Link className="btn btn-secondary" to={`${GRADING_BELOW_SIXTY_PATH}${backQs ? `?${backQs}` : ''}`}>
|
||||
Below 60
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-4 col-lg-8">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h2 className="mb-0">Schedule meeting</h2>
|
||||
<Link
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
to={`${GRADING_BELOW_SIXTY_PATH}${backQs ? `?${backQs}` : ''}`}
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{studentName ? <p className="text-muted">Student: {studentName}</p> : null}
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{done ? <div className="alert alert-success">{done}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading ? (
|
||||
<form onSubmit={(ev) => void onSubmit(ev)}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Meeting time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="form-control"
|
||||
value={meetingAt}
|
||||
onChange={(e) => setMeetingAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Notes</label>
|
||||
<textarea className="form-control" rows={5} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Administrator grading SPA (JWT `/api/v1/administrator/grading/*`). Hub at `/app/grading`. */
|
||||
export const GRADING_PATH = '/app/grading'
|
||||
/** SPA list + links; Laravel API paths remain `.../below-sixty` (see `api/grading.ts`). */
|
||||
export const GRADING_BELOW_SIXTY_PATH = `${GRADING_PATH}/below-60`
|
||||
export const GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH = `${GRADING_PATH}/below-60/email-editor`
|
||||
export const GRADING_HOMEWORK_TRACKING_PATH = `${GRADING_PATH}/homework-tracking`
|
||||
export const GRADING_PARTICIPATION_PATH = `${GRADING_PATH}/participation`
|
||||
export const GRADING_PLACEMENT_INDEX_PATH = `${GRADING_PATH}/placement-index`
|
||||
export const GRADING_PLACEMENT_PATH = `${GRADING_PATH}/placement`
|
||||
export const GRADING_SCHEDULE_MEETING_PATH = `${GRADING_PATH}/schedule-meeting`
|
||||
|
||||
export function gradingScoresPath(scoreType: string): string {
|
||||
return `${GRADING_PATH}/scores/${scoreType}`
|
||||
}
|
||||
|
||||
export function gradingPlacementBatchPath(batchId: string | number): string {
|
||||
return `${GRADING_PATH}/placement-batch/${batchId}`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Best-effort extraction when API payload shapes vary. */
|
||||
|
||||
export function rowsFromUnknown(
|
||||
data: unknown,
|
||||
keys = ['rows', 'students', 'entries'] as const,
|
||||
): Record<string, unknown>[] {
|
||||
if (Array.isArray(data)) return data as Record<string, unknown>[]
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const o = data as Record<string, unknown>
|
||||
for (const k of keys) {
|
||||
const v = o[k]
|
||||
if (Array.isArray(v)) return v as Record<string, unknown>[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function sectionsFromUnknown(data: unknown): Record<string, unknown>[] {
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const o = data as Record<string, unknown>
|
||||
for (const k of ['sections', 'class_sections', 'classes'] as const) {
|
||||
const v = o[k]
|
||||
if (Array.isArray(v)) return v as Record<string, unknown>[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function trackingSectionsFromPayload(data: unknown): {
|
||||
title: string
|
||||
rows: Record<string, unknown>[]
|
||||
}[] {
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const o = data as Record<string, unknown>
|
||||
if (Array.isArray(o.sections)) {
|
||||
return (o.sections as unknown[]).map((s, i) => {
|
||||
const sec = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
|
||||
const title = String(sec.name ?? sec.title ?? sec.class_section_name ?? `Section ${i + 1}`)
|
||||
const rows = rowsFromUnknown(sec)
|
||||
return { title, rows }
|
||||
})
|
||||
}
|
||||
const flat = rowsFromUnknown(o)
|
||||
return flat.length ? [{ title: 'Homework tracking', rows: flat }] : []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const GRADING_SCORE_TYPES = [
|
||||
'homework',
|
||||
'quiz',
|
||||
'project',
|
||||
'test',
|
||||
'midterm',
|
||||
'final',
|
||||
'comments',
|
||||
] as const
|
||||
|
||||
export type GradingScoreType = (typeof GRADING_SCORE_TYPES)[number]
|
||||
|
||||
export function isGradingScoreType(s: string): s is GradingScoreType {
|
||||
return (GRADING_SCORE_TYPES as readonly string[]).includes(s)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user