fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
@@ -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 &lt; 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',
}