add admin pages
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, 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'
|
||||
|
||||
function PageShell({ 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>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchAdministratorEmergencyContacts()
|
||||
setGroups(data.groups ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load emergency contacts.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
async function removeContact(contactId: number, parentId: number) {
|
||||
await deleteAdministratorEmergencyContact(contactId, parentId)
|
||||
await load()
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title="Emergency Contact Information">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<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>
|
||||
</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>{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>
|
||||
</td>
|
||||
</tr>
|
||||
)),
|
||||
)}
|
||||
{groups.length === 0 ? <tr><td colSpan={6} className="text-muted">No emergency contacts found.</td></tr> : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminEmergencyContactEditPage() {
|
||||
const { contactId } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const parentId = Number(searchParams.get('parent_id') ?? 0)
|
||||
const [form, setForm] = useState({ name: '', cellphone: '', email: '', relation: '' })
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!contactId || !parentId) return
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await fetchAdministratorEmergencyContact(Number(contactId), parentId)
|
||||
setForm({
|
||||
name: data.contact.name ?? '',
|
||||
cellphone: data.contact.cellphone ?? '',
|
||||
email: data.contact.email ?? '',
|
||||
relation: data.contact.relation ?? '',
|
||||
})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load emergency contact.')
|
||||
}
|
||||
})()
|
||||
}, [contactId, parentId])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!contactId || !parentId) return
|
||||
try {
|
||||
await updateAdministratorEmergencyContact(Number(contactId), {
|
||||
parent_id: parentId,
|
||||
name: form.name,
|
||||
cellphone: form.cellphone,
|
||||
email: form.email || null,
|
||||
relation: form.relation || null,
|
||||
})
|
||||
setMessage('Emergency contact updated.')
|
||||
setError(null)
|
||||
navigate('/app/administrator/emergency-contacts')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to update emergency contact.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title="Edit Emergency Contact">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{message ? <div className="alert alert-success">{message}</div> : null}
|
||||
<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="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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+188
-69
@@ -1,29 +1,39 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchDashboardRoute } from '../api/session'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
fetchAdministratorDashboardMetrics,
|
||||
searchAdministratorDashboard,
|
||||
} from '../api/session'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import type {
|
||||
AdministratorDashboardMetricsResponse,
|
||||
AdministratorDashboardSearchResponse,
|
||||
} from '../api/types'
|
||||
|
||||
export function AppHome() {
|
||||
const { user } = useAuth()
|
||||
const [route, setRoute] = useState<string | null>(null)
|
||||
const [roles, setRoles] = useState<string[]>([])
|
||||
const [metrics, setMetrics] = useState<AdministratorDashboardMetricsResponse | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [search, setSearch] = useState<AdministratorDashboardSearchResponse | null>(null)
|
||||
const [loadingMetrics, setLoadingMetrics] = useState(true)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoadingMetrics(true)
|
||||
try {
|
||||
const res = await fetchDashboardRoute()
|
||||
const d = res.data?.dashboard
|
||||
if (!cancelled && d) {
|
||||
setRoute(d.route)
|
||||
setRoles(d.roles ?? [])
|
||||
const data = await fetchAdministratorDashboardMetrics()
|
||||
if (!cancelled) {
|
||||
setMetrics(data)
|
||||
setError(null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load dashboard.')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingMetrics(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
@@ -31,74 +41,183 @@ export function AppHome() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const roleKeys = user?.roles ? Object.keys(user.roles).filter((k) => user.roles[k]) : []
|
||||
async function onSearch(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setSearching(true)
|
||||
setError(null)
|
||||
try {
|
||||
const results = await searchAdministratorDashboard(query)
|
||||
setSearch(results)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to search dashboard records.')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const counts = metrics?.counts ?? {}
|
||||
const statCards = [
|
||||
{ key: 'students', title: 'Students', icon: 'bi bi-mortarboard', value: counts.students ?? 0, tone: 'primary' },
|
||||
{ key: 'teachers', title: 'Teachers', icon: 'bi bi-person-video3', value: counts.teachers ?? 0, tone: 'success' },
|
||||
{ key: 'teacherAssistants', title: 'Teacher Assistants', icon: 'bi bi-people', value: counts.teacherAssistants ?? 0, tone: 'warning' },
|
||||
{ key: 'admins', title: 'Admins', icon: 'bi bi-person-badge', value: counts.admins ?? 0, tone: 'info' },
|
||||
{ key: 'parents', title: 'Parents', icon: 'bi bi-people-fill', value: counts.parents ?? 0, tone: 'light' },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="h4 mb-3">Welcome</h2>
|
||||
<p className="lead mb-4">
|
||||
Signed in as <strong>{user?.name}</strong>.
|
||||
</p>
|
||||
<div className="container-fluid admin-dashboard">
|
||||
<section className="mb-4">
|
||||
<h2>Welcome, {user?.name || 'Administrator'}</h2>
|
||||
<p>Here is an overview of your site's activity.</p>
|
||||
</section>
|
||||
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-6">
|
||||
<div className="card border-0 shadow-sm h-100">
|
||||
<div className="card-body">
|
||||
<h3 className="h6 text-muted">Roles (from login)</h3>
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-3">Search</h2>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<form className="mb-3" onSubmit={onSearch}>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search name, email, phone, school ID, badge code..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<button className="btn btn-outline-secondary" type="submit" disabled={searching}>
|
||||
{searching ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-text">Supports Users, Parents, Staff, Students, and Emergency Contacts.</div>
|
||||
</form>
|
||||
|
||||
{search?.query ? (
|
||||
<div className="mb-2 small text-muted">
|
||||
Showing results for: <strong>{search.query}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{search && (
|
||||
<div className="row g-3">
|
||||
<SearchTable title="Users" icon="bi bi-people" columns={['Name', 'Email', 'Phone', 'School ID']} rows={search.results.users ?? []} renderRow={(row) => [
|
||||
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '—',
|
||||
stringValue(row.email),
|
||||
stringValue(row.cellphone),
|
||||
stringValue(row.school_id),
|
||||
]} />
|
||||
<SearchTable title="Students" icon="bi bi-mortarboard" columns={['School ID', 'Name', 'Gender', 'DOB']} rows={search.results.students ?? []} renderRow={(row) => [
|
||||
stringValue(row.school_id),
|
||||
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '—',
|
||||
stringValue(row.gender),
|
||||
stringValue(row.dob),
|
||||
]} />
|
||||
<SearchTable title="Parents" icon="bi bi-people-fill" columns={['First Parent ID', 'Second Parent', 'Email', 'Phone']} rows={search.results.parents ?? []} renderRow={(row) => [
|
||||
stringValue(row.firstparent_id),
|
||||
`${stringValue(row.secondparent_firstname)} ${stringValue(row.secondparent_lastname)}`.trim() || '—',
|
||||
stringValue(row.secondparent_email),
|
||||
stringValue(row.secondparent_phone),
|
||||
]} />
|
||||
<SearchTable title="Staff" icon="bi bi-person-badge" columns={['Role', 'Name', 'Email', 'Phone', 'Status']} rows={search.results.staff ?? []} renderRow={(row) => [
|
||||
stringValue(row.role_name),
|
||||
`${stringValue(row.firstname)} ${stringValue(row.lastname)}`.trim() || '—',
|
||||
stringValue(row.email),
|
||||
stringValue(row.phone),
|
||||
stringValue(row.active_role),
|
||||
]} />
|
||||
<SearchTable title="Emergency Contacts" icon="bi bi-telephone-outbound" columns={['Name', 'Relation', 'Phone', 'Email']} rows={search.results.emergency_contacts ?? []} renderRow={(row) => [
|
||||
stringValue(row.emergency_contact_name),
|
||||
stringValue(row.relation),
|
||||
stringValue(row.cellphone),
|
||||
stringValue(row.email),
|
||||
]} fullWidth />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-4 text-center">Statistics</h2>
|
||||
{loadingMetrics ? (
|
||||
<p className="text-muted text-center">Loading...</p>
|
||||
) : (
|
||||
<div className="stats-row">
|
||||
{statCards.map((item) => (
|
||||
<div key={item.key} className={`stat-circle circle-${item.tone}`}>
|
||||
<div className="stat-icon"><i className={item.icon} aria-hidden /></div>
|
||||
<div className="stat-value">{item.value}</div>
|
||||
<div className="stat-title">{item.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3">Recent Activity</h2>
|
||||
<div className="card border-0 shadow-sm">
|
||||
<div className="card-body">
|
||||
{(metrics?.recentActivities ?? []).length === 0 ? (
|
||||
<p className="text-muted mb-0">No recent login activity.</p>
|
||||
) : (
|
||||
<ul className="mb-0">
|
||||
{roleKeys.length ? (
|
||||
roleKeys.map((r) => <li key={r}>{r}</li>)
|
||||
) : (
|
||||
<li className="text-muted">None reported</li>
|
||||
)}
|
||||
{(metrics?.recentActivities ?? []).map((item, index) => (
|
||||
<li key={`${item.email ?? 'activity'}-${index}`}>
|
||||
{item.email ?? 'Unknown user'} <span className="text-muted">· {item.login_time ?? '—'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="card border-0 shadow-sm h-100">
|
||||
<div className="card-body">
|
||||
<h3 className="h6 text-muted">Dashboard route (API)</h3>
|
||||
{error ? (
|
||||
<p className="text-danger small mb-0">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-1">
|
||||
<code>{route ?? '—'}</code>
|
||||
</p>
|
||||
<p className="small text-muted mb-0">
|
||||
Resolved roles: {roles.length ? roles.join(', ') : '—'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<h3 className="h6">Shortcuts</h3>
|
||||
<ul className="mb-0">
|
||||
<li>
|
||||
<Link to="/app/parent/home">Parent — Dashboard</Link> (parity with
|
||||
CodeIgniter <code>parent/parent_dashboard</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/app/parent/attendance">Parent — Attendance</Link> (parity
|
||||
with CodeIgniter <code>Views/parent/attendance.php</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/app/parent/home">Parent — All pages</Link> (navigation for
|
||||
every view under <code>Views/parent/</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/docs">Public API docs (Swagger)</Link>
|
||||
</li>
|
||||
<li>
|
||||
<code>app/Views</code> screen as an <code>/app/…</code> parity route
|
||||
</li>
|
||||
</ul>
|
||||
function SearchTable({
|
||||
title,
|
||||
icon,
|
||||
columns,
|
||||
rows,
|
||||
renderRow,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
title: string
|
||||
icon: string
|
||||
columns: string[]
|
||||
rows: Record<string, unknown>[]
|
||||
renderRow: (row: Record<string, unknown>) => string[]
|
||||
fullWidth?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`col-12 ${fullWidth ? '' : 'col-lg-6'}`}>
|
||||
<div className="card">
|
||||
<div className="card-header"><i className={`${icon} me-1`} aria-hidden />{title}</div>
|
||||
<div className="card-body p-0">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={columns.length} className="text-muted">No {title.toLowerCase()} found.</td></tr>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<tr key={String(row.id ?? `${title}-${index}`)}>
|
||||
{renderRow(row).map((cell, cellIndex) => <td key={`${index}-${cellIndex}`}>{cell || '—'}</td>)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return value == null || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
fetchCompetitionScoresDetail,
|
||||
fetchCompetitionScoresIndex,
|
||||
fetchPublishedCompetition,
|
||||
fetchPublishedCompetitions,
|
||||
saveCompetitionScores,
|
||||
} from '../api/session'
|
||||
import type {
|
||||
CompetitionScoreStudentRow,
|
||||
CompetitionScoresEditResponse,
|
||||
CompetitionScoresIndexCompetitionRow,
|
||||
PublicCompetitionRow,
|
||||
PublicCompetitionWinnerRow,
|
||||
} from '../api/types'
|
||||
|
||||
function PageShell({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<div className="mb-4">
|
||||
<h2 className="h4 mb-1">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function competitionLabel(row?: PublicCompetitionRow | null) {
|
||||
return row?.title ?? `Competition #${row?.id ?? ''}`
|
||||
}
|
||||
|
||||
function noteCard(children: React.ReactNode) {
|
||||
return <div className="alert alert-warning">{children}</div>
|
||||
}
|
||||
|
||||
export function CompetitionWinnersIndexPage() {
|
||||
const [rows, setRows] = useState<PublicCompetitionRow[]>([])
|
||||
const [teacherRows, setTeacherRows] = useState<CompetitionScoresIndexCompetitionRow[]>([])
|
||||
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') {
|
||||
setRows(published.value.data?.competitions ?? [])
|
||||
}
|
||||
|
||||
if (teacher.status === 'fulfilled') {
|
||||
setTeacherRows(teacher.value.competitions ?? [])
|
||||
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.')
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PageShell title="Competition Winners">
|
||||
{noteCard(
|
||||
<>
|
||||
The current API exposes published winners data and teacher score entry. I could not find routed admin APIs for
|
||||
create, settings, publish, lock, unlock, export quiz, or recognition PDF.
|
||||
</>,
|
||||
)}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header">Published Competitions</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-sm align-middle mb-0">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Semester</th>
|
||||
<th>School Year</th>
|
||||
<th>Published</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-muted">No published competitions found.</td></tr>
|
||||
) : rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.id}</td>
|
||||
<td>{competitionLabel(row)}</td>
|
||||
<td>{row.semester ?? '—'}</td>
|
||||
<td>{row.school_year ?? '—'}</td>
|
||||
<td>{formatDate(row.published_at)}</td>
|
||||
<td className="d-flex gap-2 flex-wrap">
|
||||
<Link className="btn btn-sm btn-outline-secondary" to={`/app/admin/competition-winners/${row.id}/preview`}>Preview</Link>
|
||||
<Link className="btn btn-sm btn-outline-secondary" to={`/app/admin/competition-winners/${row.id}/winners`}>Winners</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">Teacher Score Entry Competitions{activeClassName ? ` - ${activeClassName}` : ''}</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-sm align-middle mb-0">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Published</th>
|
||||
<th>Locked</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teacherRows.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-muted">No teacher competition score entries available for this account.</td></tr>
|
||||
) : teacherRows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.id}</td>
|
||||
<td>{row.title ?? `Competition #${row.id}`}</td>
|
||||
<td>{row.is_published ? 'Yes' : 'No'}</td>
|
||||
<td>{row.is_locked ? 'Yes' : 'No'}</td>
|
||||
<td>
|
||||
<Link className="btn btn-sm btn-outline-primary" to={`/app/admin/competition-winners/${row.id}`}>Enter Scores</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompetitionWinnerFormPage() {
|
||||
const { id } = useParams()
|
||||
|
||||
return (
|
||||
<PageShell title={id ? 'Edit Competition' : 'Create Competition'}>
|
||||
{noteCard(
|
||||
<>
|
||||
This screen depends on admin competition management endpoints that are not currently routed in the Laravel API.
|
||||
The SPA can show published winners and teacher score entry, but it cannot safely create or update competition settings
|
||||
without those endpoints.
|
||||
</>,
|
||||
)}
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-md-8"><label className="form-label">Title</label><input className="form-control" disabled value="" /></div>
|
||||
<div className="col-md-4"><label className="form-label">Class Section</label><input className="form-control" disabled value="" /></div>
|
||||
<div className="col-md-3"><label className="form-label">Semester</label><input className="form-control" disabled value="" /></div>
|
||||
<div className="col-md-3"><label className="form-label">School Year</label><input className="form-control" disabled value="" /></div>
|
||||
<div className="col-md-3"><label className="form-label">Start Date</label><input className="form-control" disabled value="" /></div>
|
||||
<div className="col-md-3"><label className="form-label">End Date</label><input className="form-control" disabled value="" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<PageShell title="Enter Scores">
|
||||
{noteCard(
|
||||
<>
|
||||
This page uses the teacher-facing `competition-scores` API. It works only when the logged-in account is assigned to the
|
||||
target class section.
|
||||
</>,
|
||||
)}
|
||||
{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">
|
||||
<button className="btn btn-primary" disabled={Boolean(data.isLocked)}>Save Scores</button>
|
||||
<Link className="btn btn-outline-secondary" to={`/app/admin/competition-winners/${competitionId}/preview`}>Preview</Link>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted mb-0">No competition data loaded.</p>
|
||||
)}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
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 [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 }
|
||||
}
|
||||
|
||||
export function CompetitionWinnerPreviewPage() {
|
||||
const { id } = useParams()
|
||||
const competitionId = Number(id)
|
||||
const { competition, winnersByClass, sectionMap, error } = usePublishedCompetition(competitionId)
|
||||
|
||||
return (
|
||||
<PageShell title="Preview Winners">
|
||||
{noteCard('This preview is backed by the public published-winners API. Unpublished preview data is not exposed by the current backend.')}
|
||||
{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}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompetitionWinnerWinnersPage() {
|
||||
const { id } = useParams()
|
||||
const competitionId = Number(id)
|
||||
const { competition, winnersByClass, sectionMap, questionCounts, error } = usePublishedCompetition(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 (
|
||||
<PageShell title="School Winners">
|
||||
{noteCard('This page uses the public published-winners API. The admin-only recognition PDF endpoint is not currently exposed in the Laravel API.')}
|
||||
{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}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -8,22 +8,19 @@ export function RoleSelectPage() {
|
||||
const { user, roles, selectedRole, setSelectedRole } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [pickedRole, setPickedRole] = useState<string | null>(selectedRole)
|
||||
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() {
|
||||
if (!pickedRole) {
|
||||
setError('Please select a role to continue.')
|
||||
return
|
||||
}
|
||||
async function continueLogin(nextRole: string) {
|
||||
setBusy(true)
|
||||
setBusyRole(nextRole)
|
||||
setError(null)
|
||||
setSelectedRole(pickedRole)
|
||||
setSelectedRole(nextRole)
|
||||
|
||||
let target = from.startsWith('/app') ? from : '/app/home'
|
||||
if (pickedRole.toLowerCase() === 'parent' && target === '/app/home') {
|
||||
if (nextRole.toLowerCase() === 'parent' && target === '/app/home') {
|
||||
target = '/app/parent/home'
|
||||
}
|
||||
try {
|
||||
@@ -39,41 +36,43 @@ export function RoleSelectPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-4">
|
||||
<div className="card border-0 shadow-sm mx-auto" style={{ maxWidth: 640 }}>
|
||||
<div className="card-body p-4">
|
||||
<h2 className="h4 mb-2">Select role</h2>
|
||||
<p className="text-muted mb-3">
|
||||
{user?.name ? `Signed in as ${user.name}.` : 'Signed in successfully.'} Choose a role
|
||||
to continue.
|
||||
<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>
|
||||
<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="list-group mb-3">
|
||||
<div className="d-flex flex-wrap gap-3">
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action text-start ${
|
||||
pickedRole === role ? 'active' : ''
|
||||
className={`btn ${
|
||||
selectedRole === role ? 'btn-primary' : 'btn-outline-primary'
|
||||
}`}
|
||||
onClick={() => setPickedRole(role)}
|
||||
onClick={() => {
|
||||
void continueLogin(role)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{role}
|
||||
{busy && busyRole === role
|
||||
? 'Opening...'
|
||||
: role.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
<div className="alert alert-danger py-2 mt-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="d-grid">
|
||||
<button type="button" className="btn btn-success" onClick={continueLogin} disabled={busy}>
|
||||
{busy ? 'Continuing…' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user