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,375 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { formatProgressDate } from '../classProgress/classProgressUtils'
import { ClassProgressUnitDisplay } from '../classProgress/ClassProgressUnitDisplay'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
const STATUS_OPTIONS: Record<string, string> = {
on_track: 'On track',
slightly_behind: 'Slightly behind',
behind: 'Behind',
}
type Pagination = {
current_page?: number
last_page?: number
per_page?: number
total?: number
}
export type TeacherProgressReportRow = {
id?: number
class_section_id?: number
class_section_name?: string | null
week_start?: string | null
week_end?: string | null
subject?: string | null
unit_title?: string | null
covered?: string | null
homework?: string | null
status?: string | null
status_label?: string | null
teacher_name?: string | null
updated_at?: string | null
}
/** Laravel `JsonResource::collection()` often serializes as `{ data: [...] }` under `items`. */
function extractReportRows(rawItems: unknown): TeacherProgressReportRow[] {
if (rawItems == null) return []
if (Array.isArray(rawItems)) return rawItems as TeacherProgressReportRow[]
if (typeof rawItems === 'object' && 'data' in (rawItems as object)) {
const inner = (rawItems as { data: unknown }).data
if (Array.isArray(inner)) return inner as TeacherProgressReportRow[]
}
return []
}
function unwrapHistoryList(raw: unknown): { items: TeacherProgressReportRow[]; pagination?: Pagination } | null {
if (raw == null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
let data = (o.data ?? o) as Record<string, unknown>
// Rare: double-wrapped `data` from intermediaries
if (!('items' in data) && typeof data.data === 'object' && data.data !== null) {
const inner = data.data as Record<string, unknown>
if ('items' in inner) data = inner
}
if (!('items' in data)) return null
const rows = extractReportRows(data.items)
return {
items: rows,
pagination: data.pagination as Pagination | undefined,
}
}
/** Empty values (e.g. `week_start=`) break Laravel `date_format` validation on the index endpoint. */
function buildHistoryQueryString(qs: string): string {
const p = new URLSearchParams(qs || undefined)
const emptyKeys: string[] = []
p.forEach((v, k) => {
if (v === '' || v == null) emptyKeys.push(k)
})
emptyKeys.forEach((k) => p.delete(k))
if (!p.has('per_page')) p.set('per_page', '100')
if (!p.has('sort_by')) p.set('sort_by', 'week_start')
if (!p.has('sort_dir')) p.set('sort_dir', 'desc')
return p.toString()
}
function statusBadgeClass(status: string | null | undefined): string {
const s = String(status ?? '').toLowerCase()
if (s === 'on_track') return 'text-bg-success'
if (s === 'slightly_behind') return 'text-bg-warning text-dark'
if (s === 'behind') return 'text-bg-danger'
return 'text-bg-secondary'
}
function truncate(s: string | null | undefined, max: number): string {
const t = String(s ?? '').trim()
if (t.length <= max) return t
return `${t.slice(0, max - 1)}`
}
/** `GET /api/v1/teacher/class-progress-history` — paginated report rows for the logged-in teacher. */
export function TeacherClassProgressHistoryPage() {
const [search, setSearch] = useSearchParams()
const qs = search.toString()
const listPath = useMemo(() => {
return `/class-progress-history?${buildHistoryQueryString(qs)}`
}, [qs])
const { data: raw, loading, error, reload } = useTeacherResource<unknown>(listPath)
const parsed = useMemo(() => unwrapHistoryList(raw), [raw])
const items = parsed?.items ?? []
const pagination = parsed?.pagination
const [draftWeekStart, setDraftWeekStart] = useState(() => search.get('week_start') ?? '')
const [draftWeekEnd, setDraftWeekEnd] = useState(() => search.get('week_end') ?? '')
const [draftSection, setDraftSection] = useState(() => search.get('class_section_id') ?? '')
const [draftStatus, setDraftStatus] = useState(() => search.get('status') ?? '')
const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc')
useEffect(() => {
const p = new URLSearchParams(buildHistoryQueryString(qs))
setDraftWeekStart(p.get('week_start') ?? '')
setDraftWeekEnd(p.get('week_end') ?? '')
setDraftSection(p.get('class_section_id') ?? '')
setDraftStatus(p.get('status') ?? '')
setDraftSortDir(p.get('sort_dir') || 'desc')
}, [qs])
const sectionOptions = useMemo(() => {
const m = new Map<number, string>()
for (const r of items) {
const id = Number(r.class_section_id ?? 0)
if (!id) continue
const name = String(r.class_section_name ?? '').trim() || `Section ${id}`
if (!m.has(id)) m.set(id, name)
}
return [...m.entries()].sort((a, b) => a[0] - b[0])
}, [items])
function applyFilters(e: FormEvent) {
e.preventDefault()
const next = new URLSearchParams()
next.set('per_page', search.get('per_page') || '100')
next.set('sort_by', search.get('sort_by') || 'week_start')
next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc')
if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim())
if (draftWeekEnd.trim()) next.set('week_end', draftWeekEnd.trim())
if (draftSection.trim()) next.set('class_section_id', draftSection.trim())
if (draftStatus.trim()) next.set('status', draftStatus.trim())
next.delete('page')
setSearch(next, { replace: true })
}
function resetFilters() {
setDraftWeekStart('')
setDraftWeekEnd('')
setDraftSection('')
setDraftStatus('')
const next = new URLSearchParams()
next.set('per_page', '100')
next.set('sort_by', 'week_start')
next.set('sort_dir', 'desc')
setDraftSortDir('desc')
setSearch(next, { replace: true })
}
const currentPage = Math.max(1, Number(pagination?.current_page) || 1)
const lastPage = Math.max(1, Number(pagination?.last_page) || 1)
const total = pagination?.total != null ? Number(pagination.total) : items.length
function goPage(p: number) {
const next = new URLSearchParams(buildHistoryQueryString(qs))
if (p <= 1) next.delete('page')
else next.set('page', String(p))
setSearch(next, { replace: true })
}
return (
<TeacherShell
title="Class progress — history"
subtitle="Your submitted weekly progress reports."
loading={loading}
error={error}
>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<Link to="/app/teacher/class-progress-submit" className="btn btn-outline-primary btn-sm">
Submit progress
</Link>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => reload()}>
Refresh
</button>
</div>
{!loading && raw != null && parsed == null ? (
<div className="alert alert-warning">
Unexpected response shape.
<pre className="small mb-0 mt-2 bg-light p-2 rounded overflow-auto" style={{ maxHeight: 200 }}>
{JSON.stringify(raw, null, 2)}
</pre>
</div>
) : null}
<form className="card border mb-3" onSubmit={applyFilters}>
<div className="card-body py-3">
<div className="row g-2 align-items-end">
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-ws">
Week from
</label>
<input
id="hist-ws"
type="date"
className="form-control form-control-sm"
value={draftWeekStart}
onChange={(e) => setDraftWeekStart(e.target.value)}
/>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-we">
Week to
</label>
<input
id="hist-we"
type="date"
className="form-control form-control-sm"
value={draftWeekEnd}
onChange={(e) => setDraftWeekEnd(e.target.value)}
/>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-sec">
Section
</label>
<select
id="hist-sec"
className="form-select form-select-sm"
value={draftSection}
onChange={(e) => setDraftSection(e.target.value)}
>
<option value="">All</option>
{sectionOptions.map(([id, name]) => (
<option key={id} value={String(id)}>
{name}
</option>
))}
</select>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-st">
Status
</label>
<select
id="hist-st"
className="form-select form-select-sm"
value={draftStatus}
onChange={(e) => setDraftStatus(e.target.value)}
>
<option value="">All</option>
{Object.entries(STATUS_OPTIONS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-sort">
Week order
</label>
<select
id="hist-sort"
className="form-select form-select-sm"
value={draftSortDir}
onChange={(e) => setDraftSortDir(e.target.value)}
>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
<div className="col-md-12 col-lg-4 d-flex flex-wrap gap-2">
<button type="submit" className="btn btn-primary btn-sm">
Apply filters
</button>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={resetFilters}>
Reset
</button>
</div>
</div>
</div>
</form>
{items.length === 0 && !loading ? (
<p className="text-muted mb-0">No progress reports match these filters.</p>
) : null}
{items.length > 0 ? (
<>
<div className="table-responsive border rounded bg-white shadow-sm">
<table className="table table-sm table-striped table-hover align-middle mb-0">
<thead className="table-light">
<tr>
<th scope="col">Week</th>
<th scope="col">Section</th>
<th scope="col">Subject</th>
<th scope="col">Status</th>
<th scope="col">Unit</th>
<th scope="col">Covered</th>
<th scope="col">Homework</th>
<th scope="col" className="text-nowrap">
Updated
</th>
</tr>
</thead>
<tbody>
{items.map((row) => {
const id = Number(row.id ?? 0)
const week =
row.week_start || row.week_end
? `${formatProgressDate(row.week_start)} ${formatProgressDate(row.week_end)}`
: '—'
const section = String(row.class_section_name ?? '').trim() || `ID ${row.class_section_id ?? '—'}`
const subject = String(row.subject ?? '—')
const isQuran = subject.toLowerCase().includes('quran')
const st = row.status_label ?? STATUS_OPTIONS[String(row.status ?? '')] ?? row.status ?? '—'
return (
<tr key={id || `${week}-${subject}`}>
<td className="text-nowrap small">{week}</td>
<td className="small">{section}</td>
<td className="small fw-semibold">{subject}</td>
<td>
<span className={`badge rounded-pill ${statusBadgeClass(row.status)}`}>{st}</span>
</td>
<td className="small">
<ClassProgressUnitDisplay unitTitle={row.unit_title} isQuran={isQuran} compact />
</td>
<td className="small text-muted" title={row.covered ?? undefined}>
{truncate(row.covered, 120) || '—'}
</td>
<td className="small text-muted" title={row.homework ?? undefined}>
{truncate(row.homework, 80) || '—'}
</td>
<td className="small text-nowrap text-muted">
{formatProgressDate(row.updated_at, true)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{lastPage > 1 ? (
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mt-3">
<span className="small text-muted">
Page {currentPage} of {lastPage} ({total} total)
</span>
<div className="btn-group btn-group-sm">
<button
type="button"
className="btn btn-outline-secondary"
disabled={currentPage <= 1}
onClick={() => goPage(currentPage - 1)}
>
Previous
</button>
<button
type="button"
className="btn btn-outline-secondary"
disabled={currentPage >= lastPage}
onClick={() => goPage(currentPage + 1)}
>
Next
</button>
</div>
</div>
) : null}
</>
) : null}
</TeacherShell>
)
}
@@ -0,0 +1,684 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError, apiFetch } from '../../api/http'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
const CUSTOM_UNIT = 'Custom'
export type CurriculumRow = {
unit_number?: number | null
unit_title?: string | null
chapter_name?: string | null
}
export type ClassProgressSubmitFormData = {
subject_sections?: Record<string, { label?: string; db_subject?: string }>
status_options?: Record<string, string>
sunday_options?: string[]
curriculum?: Record<string, CurriculumRow[]>
classes?: Array<{
class_section_id?: number
class_section_name?: string
class_id?: number | null
class_name?: string | null
}>
defaults?: {
class_id?: number | null
class_section_id?: number | null
school_year?: string | null
semester?: string | null
week_start?: string | null
}
}
type UnitRow = {
unitNumber: number | 'custom' | null
chapter: string
}
function unwrapSubmitForm(raw: unknown): ClassProgressSubmitFormData | null {
if (raw == null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
const inner = o.data
if (inner != null && typeof inner === 'object' && !Array.isArray(inner)) {
return inner as ClassProgressSubmitFormData
}
return raw as ClassProgressSubmitFormData
}
function formatWeekLabel(iso: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
if (!m) return iso
return `${m[2]}/${m[3]}/${m[1]}`
}
function unitChoices(curriculum: CurriculumRow[]): Array<{ value: number; label: string }> {
const seen = new Map<number, string>()
for (const r of curriculum) {
const n = Number(r.unit_number ?? 0)
if (!Number.isFinite(n) || n <= 0) continue
if (!seen.has(n)) {
const t = String(r.unit_title ?? '').trim()
seen.set(n, t || `Unit ${n}`)
}
}
return [...seen.entries()]
.sort((a, b) => a[0] - b[0])
.map(([value, label]) => ({ value, label }))
}
function chaptersForUnit(curriculum: CurriculumRow[], unitNumber: number): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const r of curriculum) {
if (Number(r.unit_number ?? 0) !== unitNumber) continue
const ch = String(r.chapter_name ?? '').trim()
if (ch && !seen.has(ch)) {
seen.add(ch)
out.push(ch)
}
}
return out
}
function unitLabelForPayload(curriculum: CurriculumRow[], unitNumber: number): string {
for (const r of curriculum) {
if (Number(r.unit_number ?? 0) === unitNumber) {
const t = String(r.unit_title ?? '').trim()
if (t) return t
}
}
return `Unit ${unitNumber}`
}
function islamicSelectionValid(rows: UnitRow[]): boolean {
for (const r of rows) {
const ch = r.chapter.trim()
if (r.unitNumber === 'custom' && ch !== '') return true
if (typeof r.unitNumber === 'number' && r.unitNumber > 0 && ch !== '') return true
if (typeof r.unitNumber === 'number' && r.unitNumber > 0 && ch === '') continue
}
return false
}
function appendArray(fd: FormData, key: string, values: string[]) {
values.forEach((v, i) => {
fd.append(`${key}[${i}]`, v)
})
}
/** `teacher/class_progress_submit.php` — weekly progress form; POST `/api/v1/teacher/class-progress-submit`. */
export function TeacherClassProgressSubmitPage() {
const [search] = useSearchParams()
const classIdQ = search.get('class_id')
const path = useMemo(() => {
const params = new URLSearchParams()
if (classIdQ) params.set('class_id', classIdQ)
const q = params.toString()
return `/class-progress-submit${q ? `?${q}` : ''}`
}, [classIdQ])
const { data: raw, loading, error, reload } = useTeacherResource<unknown>(path)
const data = useMemo(() => unwrapSubmitForm(raw), [raw])
const subjectSections = data?.subject_sections ?? {}
const slugs = useMemo(() => Object.keys(subjectSections), [subjectSections])
const sundayOptions = data?.sunday_options ?? []
const classes = data?.classes ?? []
const defaults = data?.defaults ?? {}
const curriculumIslamic = data?.curriculum?.islamic ?? []
const curriculumQuran = data?.curriculum?.quran ?? []
const unitPickList = useMemo(() => unitChoices(curriculumIslamic), [curriculumIslamic])
const classIds = useMemo(() => {
const s = new Set<number>()
for (const c of classes) {
const id = Number(c.class_id ?? 0)
if (id > 0) s.add(id)
}
return [...s].sort((a, b) => a - b)
}, [classes])
const [weekStart, setWeekStart] = useState('')
const [classSectionId, setClassSectionId] = useState<number | ''>('')
const [covered, setCovered] = useState<Record<string, string>>({})
const [homework, setHomework] = useState<Record<string, string>>({})
const [islamicRows, setIslamicRows] = useState<UnitRow[]>([{ unitNumber: null, chapter: '' }])
const [quranRows, setQuranRows] = useState<UnitRow[]>([{ unitNumber: null, chapter: '' }])
const [attachmentIslamic, setAttachmentIslamic] = useState<File[]>([])
const [attachmentQuran, setAttachmentQuran] = useState<File[]>([])
const [confirmOverwrite, setConfirmOverwrite] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitOk, setSubmitOk] = useState<string | null>(null)
useEffect(() => {
if (!data) return
const ws = defaults.week_start ?? sundayOptions[0] ?? ''
setWeekStart(ws)
const sid = Number(defaults.class_section_id ?? 0)
setClassSectionId(sid > 0 ? sid : '')
setCovered({})
setHomework({})
setIslamicRows([{ unitNumber: null, chapter: '' }])
setQuranRows([{ unitNumber: null, chapter: '' }])
setAttachmentIslamic([])
setAttachmentQuran([])
setConfirmOverwrite(false)
setSubmitError(null)
setSubmitOk(null)
}, [data, defaults.class_section_id, defaults.week_start, sundayOptions])
const resolvedClassId = useMemo(() => {
const fromQ = classIdQ ? Number(classIdQ) : 0
if (fromQ > 0) return fromQ
return Number(defaults.class_id ?? 0)
}, [classIdQ, defaults.class_id])
const sectionsForClass = useMemo(() => {
if (!resolvedClassId) return classes
return classes.filter((c) => Number(c.class_id ?? 0) === resolvedClassId)
}, [classes, resolvedClassId])
useEffect(() => {
if (classSectionId === '' && sectionsForClass.length === 1) {
const only = Number(sectionsForClass[0].class_section_id ?? 0)
if (only > 0) setClassSectionId(only)
}
}, [sectionsForClass, classSectionId])
const canSubmit = useMemo(() => {
if (!weekStart || !classSectionId) return false
if (!islamicSelectionValid(islamicRows)) return false
const covIslamic = (covered.islamic ?? '').trim()
if (covIslamic === '') return false
return true
}, [weekStart, classSectionId, islamicRows, covered])
const updateIslamicRow = useCallback((idx: number, patch: Partial<UnitRow>) => {
setIslamicRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
}, [])
const updateQuranRow = useCallback((idx: number, patch: Partial<UnitRow>) => {
setQuranRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
}, [])
const handleSubmit = useCallback(async () => {
setSubmitError(null)
setSubmitOk(null)
if (!canSubmit || classSectionId === '') return
const unitIslamic: string[] = []
const chapterIslamic: string[] = []
for (const r of islamicRows) {
const ch = r.chapter.trim()
if (r.unitNumber === 'custom') {
if (ch === '') continue
unitIslamic.push(CUSTOM_UNIT)
chapterIslamic.push(ch)
} else if (typeof r.unitNumber === 'number' && r.unitNumber > 0) {
if (ch === '') continue
unitIslamic.push(unitLabelForPayload(curriculumIslamic, r.unitNumber))
chapterIslamic.push(ch)
}
}
const unitQuran: string[] = []
const chapterQuran: string[] = []
for (const r of quranRows) {
const ch = r.chapter.trim()
if (r.unitNumber === 'custom' && ch !== '') {
unitQuran.push(CUSTOM_UNIT)
chapterQuran.push(ch)
} else if (typeof r.unitNumber === 'number' && r.unitNumber > 0 && ch !== '') {
unitQuran.push(unitLabelForPayload(curriculumQuran, r.unitNumber))
chapterQuran.push(ch)
}
}
const fd = new FormData()
fd.append('class_section_id', String(classSectionId))
fd.append('week_start', weekStart)
if (confirmOverwrite) fd.append('confirm_overwrite', '1')
for (const slug of slugs) {
const cov = (covered[slug] ?? '').trim()
const hw = (homework[slug] ?? '').trim()
fd.append(`covered_${slug}`, cov)
if (hw) fd.append(`homework_${slug}`, hw)
}
appendArray(fd, 'unit_islamic', unitIslamic)
appendArray(fd, 'chapter_islamic', chapterIslamic)
appendArray(fd, 'unit_quran', unitQuran)
appendArray(fd, 'chapter_quran', chapterQuran)
attachmentIslamic.forEach((f) => fd.append('attachment_islamic[]', f))
attachmentQuran.forEach((f) => fd.append('attachment_quran[]', f))
setSubmitting(true)
try {
const res = await apiFetch<{ status?: boolean; message?: string }>('/api/v1/teacher/class-progress-submit', {
method: 'POST',
body: fd,
})
setSubmitOk(typeof res?.message === 'string' ? res.message : 'Progress saved.')
setConfirmOverwrite(false)
reload()
} catch (e: unknown) {
if (e instanceof ApiHttpError && e.status === 409) {
setSubmitError(
'A report already exists for this week. Check “Replace existing week” and submit again to overwrite.',
)
return
}
const msg = e instanceof ApiHttpError ? e.message : 'Unable to save progress.'
setSubmitError(msg)
} finally {
setSubmitting(false)
}
}, [
canSubmit,
classSectionId,
weekStart,
confirmOverwrite,
slugs,
covered,
homework,
islamicRows,
quranRows,
curriculumIslamic,
curriculumQuran,
attachmentIslamic,
attachmentQuran,
reload,
])
const subtitleParts = [defaults.school_year, defaults.semester].filter(Boolean).join(' · ')
return (
<TeacherShell
title="Submit class progress"
subtitle={subtitleParts || undefined}
loading={loading}
error={error}
>
{!loading && raw != null && data == null ? (
<div className="alert alert-warning">
Unexpected response shape.
<pre className="small mb-0 mt-2 bg-light p-2 rounded overflow-auto" style={{ maxHeight: 200 }}>
{JSON.stringify(raw, null, 2)}
</pre>
</div>
) : null}
{data ? (
<div className="d-flex flex-column gap-4">
<div className="d-flex flex-wrap justify-content-end gap-2">
<Link to="/app/teacher/class-progress-history" className="btn btn-outline-primary btn-sm">
Past submissions
</Link>
</div>
{classes.length === 0 ? (
<div className="alert alert-info mb-0">
You have no class assignments for the current term, so progress cannot be submitted.
</div>
) : null}
{classes.length > 0 && classIds.length > 1 ? (
<div className="d-flex flex-wrap align-items-center gap-2">
<span className="small text-muted me-1">Class:</span>
{classIds.map((id) => {
const label =
classes.find((c) => Number(c.class_id) === id)?.class_name?.trim() || `Class ${id}`
const active = resolvedClassId === id
return (
<Link
key={id}
to={`/app/teacher/class-progress-submit?class_id=${encodeURIComponent(String(id))}`}
className={`btn btn-sm ${active ? 'btn-primary' : 'btn-outline-primary'}`}
>
{label}
</Link>
)
})}
</div>
) : null}
{submitOk ? (
<div className="alert alert-success py-2 mb-0" role="status">
{submitOk}
</div>
) : null}
{submitError ? (
<div className="alert alert-danger py-2 mb-0" role="alert">
{submitError}
</div>
) : null}
{classes.length > 0 ? (
<>
<section className="border rounded p-3 bg-white">
<h2 className="h6 mb-3">Week &amp; section</h2>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label" htmlFor="cp-week-start">
Week (Sunday start)
</label>
<select
id="cp-week-start"
className="form-select form-select-sm"
value={weekStart}
onChange={(e) => setWeekStart(e.target.value)}
>
{sundayOptions.length === 0 ? <option value="">No dates configured</option> : null}
{sundayOptions.map((d) => (
<option key={d} value={d}>
{formatWeekLabel(d)}
</option>
))}
</select>
</div>
<div className="col-md-6">
<label className="form-label" htmlFor="cp-section">
Class section
</label>
<select
id="cp-section"
className="form-select form-select-sm"
value={classSectionId === '' ? '' : String(classSectionId)}
onChange={(e) => setClassSectionId(e.target.value ? Number(e.target.value) : '')}
>
<option value="">Select section</option>
{sectionsForClass.map((c) => {
const id = Number(c.class_section_id ?? 0)
if (!id) return null
const name = String(c.class_section_name ?? '').trim() || `Section ${id}`
return (
<option key={id} value={id}>
{name}
</option>
)
})}
</select>
</div>
</div>
</section>
<section className="border rounded p-3 bg-white">
<h2 className="h6 mb-1">Islamic Studies units / chapters</h2>
<p className="small text-muted mb-3">
Select at least one unit/chapter pair (or Custom + description). Required before submit.
</p>
<div className="d-flex flex-column gap-2">
{islamicRows.map((row, idx) => (
<div key={`is-${idx}`} className="row g-2 align-items-end">
<div className="col-md-5">
<label className="form-label small mb-0">Unit</label>
<select
className="form-select form-select-sm"
value={
row.unitNumber === 'custom'
? 'custom'
: row.unitNumber != null
? String(row.unitNumber)
: ''
}
onChange={(e) => {
const v = e.target.value
if (v === '') updateIslamicRow(idx, { unitNumber: null, chapter: '' })
else if (v === 'custom') updateIslamicRow(idx, { unitNumber: 'custom', chapter: '' })
else updateIslamicRow(idx, { unitNumber: Number(v), chapter: '' })
}}
>
<option value=""></option>
{unitPickList.map((u) => (
<option key={u.value} value={u.value}>
{u.label}
</option>
))}
<option value="custom">{CUSTOM_UNIT}</option>
</select>
</div>
<div className="col-md-5">
<label className="form-label small mb-0">Chapter / detail</label>
{row.unitNumber != null &&
row.unitNumber !== 'custom' &&
typeof row.unitNumber === 'number' &&
chaptersForUnit(curriculumIslamic, row.unitNumber).length > 0 ? (
<select
className="form-select form-select-sm"
value={row.chapter}
onChange={(e) => updateIslamicRow(idx, { chapter: e.target.value })}
>
<option value=""></option>
{chaptersForUnit(curriculumIslamic, row.unitNumber).map((ch) => (
<option key={ch} value={ch}>
{ch}
</option>
))}
</select>
) : (
<input
type="text"
className="form-control form-control-sm"
value={row.chapter}
onChange={(e) => updateIslamicRow(idx, { chapter: e.target.value })}
placeholder={row.unitNumber === 'custom' ? 'Describe custom topic…' : 'Chapter or detail'}
/>
)}
</div>
<div className="col-md-2 d-flex gap-1">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() =>
setIslamicRows((prev) => [...prev.slice(0, idx + 1), { unitNumber: null, chapter: '' }, ...prev.slice(idx + 1)])
}
>
+ Row
</button>
{islamicRows.length > 1 ? (
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => setIslamicRows((prev) => prev.filter((_, i) => i !== idx))}
>
×
</button>
) : null}
</div>
</div>
))}
</div>
</section>
{slugs.map((slug) => {
const label = subjectSections[slug]?.label ?? slug
const attKey = slug === 'islamic' ? attachmentIslamic : attachmentQuran
const setAtt = slug === 'islamic' ? setAttachmentIslamic : setAttachmentQuran
return (
<Fragment key={slug}>
{slug === 'quran' && curriculumQuran.length > 0 ? (
<section className="border rounded p-3 bg-white mb-3">
<h2 className="h6 mb-1">Quran / Arabic units / chapters (optional)</h2>
<p className="small text-muted mb-3">Optional rows when curriculum is available.</p>
<div className="d-flex flex-column gap-2">
{quranRows.map((row, idx) => {
const qUnits = unitChoices(curriculumQuran)
return (
<div key={`qr-${idx}`} className="row g-2 align-items-end">
<div className="col-md-5">
<label className="form-label small mb-0">Unit</label>
<select
className="form-select form-select-sm"
value={
row.unitNumber === 'custom'
? 'custom'
: row.unitNumber != null
? String(row.unitNumber)
: ''
}
onChange={(e) => {
const v = e.target.value
if (v === '') updateQuranRow(idx, { unitNumber: null, chapter: '' })
else if (v === 'custom') updateQuranRow(idx, { unitNumber: 'custom', chapter: '' })
else updateQuranRow(idx, { unitNumber: Number(v), chapter: '' })
}}
>
<option value=""></option>
{qUnits.map((u) => (
<option key={u.value} value={u.value}>
{u.label}
</option>
))}
<option value="custom">{CUSTOM_UNIT}</option>
</select>
</div>
<div className="col-md-5">
<label className="form-label small mb-0">Chapter / detail</label>
{row.unitNumber != null &&
row.unitNumber !== 'custom' &&
typeof row.unitNumber === 'number' &&
chaptersForUnit(curriculumQuran, row.unitNumber).length > 0 ? (
<select
className="form-select form-select-sm"
value={row.chapter}
onChange={(e) => updateQuranRow(idx, { chapter: e.target.value })}
>
<option value=""></option>
{chaptersForUnit(curriculumQuran, row.unitNumber).map((ch) => (
<option key={ch} value={ch}>
{ch}
</option>
))}
</select>
) : (
<input
type="text"
className="form-control form-control-sm"
value={row.chapter}
onChange={(e) => updateQuranRow(idx, { chapter: e.target.value })}
placeholder="Chapter or custom text…"
/>
)}
</div>
<div className="col-md-2 d-flex gap-1">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() =>
setQuranRows((prev) => [
...prev.slice(0, idx + 1),
{ unitNumber: null, chapter: '' },
...prev.slice(idx + 1),
])
}
>
+ Row
</button>
{quranRows.length > 1 ? (
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => setQuranRows((prev) => prev.filter((_, i) => i !== idx))}
>
×
</button>
) : null}
</div>
</div>
)
})}
</div>
</section>
) : null}
<section className="border rounded p-3 bg-white">
<h2 className="h6 mb-3">{label}</h2>
<div className="mb-2">
<label className="form-label small" htmlFor={`covered-${slug}`}>
Material covered <span className="text-danger">{slug === 'islamic' ? '*' : ''}</span>
</label>
<textarea
id={`covered-${slug}`}
className="form-control form-control-sm"
rows={3}
value={covered[slug] ?? ''}
onChange={(e) => setCovered((p) => ({ ...p, [slug]: e.target.value }))}
placeholder="Topics taught this week…"
/>
</div>
<div className="mb-2">
<label className="form-label small" htmlFor={`hw-${slug}`}>
Homework
</label>
<textarea
id={`hw-${slug}`}
className="form-control form-control-sm"
rows={2}
value={homework[slug] ?? ''}
onChange={(e) => setHomework((p) => ({ ...p, [slug]: e.target.value }))}
/>
</div>
<div>
<label className="form-label small" htmlFor={`att-${slug}`}>
Attachment (PDF or image)
</label>
<input
id={`att-${slug}`}
type="file"
className="form-control form-control-sm"
accept=".pdf,.png,.jpg,.jpeg,image/*,application/pdf"
multiple
onChange={(e) => setAtt(Array.from(e.target.files ?? []))}
/>
{attKey.length > 0 ? (
<div className="small text-muted mt-1">{attKey.map((f) => f.name).join(', ')}</div>
) : null}
</div>
</section>
</Fragment>
)
})}
<div className="form-check mb-2">
<input
id="cp-overwrite"
type="checkbox"
className="form-check-input"
checked={confirmOverwrite}
onChange={(e) => setConfirmOverwrite(e.target.checked)}
/>
<label className="form-check-label" htmlFor="cp-overwrite">
Replace existing progress for this week (overwrite)
</label>
</div>
<div className="d-flex flex-wrap gap-2 align-items-center">
<button
type="button"
className="btn btn-primary"
disabled={!canSubmit || submitting}
onClick={() => void handleSubmit()}
>
{submitting ? 'Saving…' : 'Submit progress'}
</button>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => reload()}>
Reload form
</button>
{!canSubmit ? (
<span className="small text-muted">
Choose section, week, Islamic unit/chapter, and material covered for Islamic Studies.
</span>
) : null}
</div>
</>
) : null}
</div>
) : null}
</TeacherShell>
)
}
@@ -0,0 +1,18 @@
.teacher-class-view__meta {
line-height: 1.55;
}
.teacher-class-view__table thead th {
background-color: #dee2e6;
color: #212529;
font-weight: 600;
vertical-align: middle;
}
.teacher-class-view__table tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
.teacher-class-view__table tbody tr:nth-child(odd) {
background-color: #fff;
}
+314
View File
@@ -0,0 +1,314 @@
import { useMemo } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useAuth } from '../../auth/AuthProvider'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
import './TeacherClassViewPage.css'
export type TeacherClassViewStudent = {
student_id?: number
firstname?: string
lastname?: string
school_id?: string | null
is_new?: unknown
photo_consent?: unknown
age?: number | null
medical_conditions?: string[]
allergies?: string[]
medical_conditions_text?: string
allergies_text?: string
}
export type TeacherClassViewClassRow = {
class_section_id?: number
class_section_name?: string
class_id?: number | null
class_name?: string | null
}
export type TeacherClassViewAssignedNames = {
mains?: string[]
tas?: string[]
others?: string[]
}
export type TeacherClassViewPayload = {
ok?: boolean
message?: string
teacher?: Record<string, unknown> | null
classes?: TeacherClassViewClassRow[]
students?: TeacherClassViewStudent[]
students_by_section?: Record<string, TeacherClassViewStudent[]>
assigned_names?: Record<string, TeacherClassViewAssignedNames>
active_class_section_id?: number | null
/** Display name for `active_class_section_id` (from `classSection`). */
class_section_name?: string | null
}
function unwrapTeacherClassView(raw: unknown): TeacherClassViewPayload | null {
if (raw == null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
const inner = o.data
if (inner != null && typeof inner === 'object' && !Array.isArray(inner)) {
return inner as TeacherClassViewPayload
}
return raw as TeacherClassViewPayload
}
function isTruthyOne(v: unknown): boolean {
if (v === true || v === 1) return true
const s = String(v).toLowerCase().trim()
return s === '1' || s === 'true' || s === 'yes'
}
function formatList(names: string[] | undefined): string {
if (!names || names.length === 0) return '—'
return names.join(', ')
}
function NewStudentBadge({ isNew }: { isNew: boolean }) {
return (
<span className={`badge rounded-pill ${isNew ? 'text-bg-warning text-dark' : 'text-bg-success'}`}>
{isNew ? 'Yes' : 'No'}
</span>
)
}
function PhotoConsentBadge({ consent }: { consent: boolean }) {
return (
<span className={`badge rounded-pill ${consent ? 'text-bg-success' : 'text-bg-danger'}`}>
{consent ? 'Yes' : 'No'}
</span>
)
}
function AllergiesCell({ allergies }: { allergies: string[] }) {
const list = (allergies ?? []).map((a) => String(a).trim()).filter(Boolean)
if (list.length === 0) {
return <span className="badge rounded-pill text-bg-success">None</span>
}
return (
<div className="d-flex flex-wrap gap-1 justify-content-center">
{list.map((a, i) => (
<span key={`${a}-${i}`} className="badge rounded-pill text-bg-warning text-dark">
{a}
</span>
))}
</div>
)
}
function MedicalCell({ conditions }: { conditions: string[] }) {
const list = (conditions ?? []).map((c) => String(c).trim()).filter(Boolean)
if (list.length === 0) {
return <span className="badge rounded-pill text-bg-success">None</span>
}
return (
<div className="d-flex flex-wrap gap-1 justify-content-center">
{list.map((c, i) => (
<span
key={`${c}-${i}`}
className="badge rounded-pill text-bg-warning text-dark text-wrap text-start mw-100"
>
{c}
</span>
))}
</div>
)
}
/** `GET /api/v1/teacher/class-view` — roster with badges (parity with legacy class view). */
export function TeacherClassViewPage() {
const { user } = useAuth()
const [search] = useSearchParams()
const sectionQ = search.get('class_section_id')?.trim() ?? ''
const authSectionId = user?.class_section_id != null ? String(user.class_section_id).trim() : ''
const effectiveSectionId = sectionQ || authSectionId
const path = useMemo(() => {
const q = effectiveSectionId ? `?class_section_id=${encodeURIComponent(effectiveSectionId)}` : ''
return `/class-view${q}`
}, [effectiveSectionId])
const { data: raw, loading, error, reload } = useTeacherResource<unknown>(path)
const payload = useMemo(() => unwrapTeacherClassView(raw), [raw])
const activeSectionId = payload?.active_class_section_id ?? null
const classes = payload?.classes ?? []
const students = payload?.students ?? []
const assigned = payload?.assigned_names ?? {}
const activeMeta = useMemo(() => {
const id = activeSectionId
if (id == null) return null
return classes.find((c) => Number(c.class_section_id) === Number(id)) ?? null
}, [classes, activeSectionId])
const namesForSection = useMemo(() => {
if (activeSectionId == null) return { mains: [] as string[], tas: [] as string[] }
const key = String(activeSectionId)
const block = assigned[key] ?? assigned[Number(activeSectionId)]
return {
mains: block?.mains ?? [],
tas: block?.tas ?? [],
}
}, [assigned, activeSectionId])
const apiSectionName = payload?.class_section_name
const gradeSectionLabel = useMemo(() => {
const sectionName =
(apiSectionName != null && String(apiSectionName).trim() !== ''
? String(apiSectionName).trim()
: null) ?? (activeMeta?.class_section_name ? String(activeMeta.class_section_name).trim() : null)
if (sectionName) return sectionName
if (activeSectionId != null) return String(activeSectionId)
return '—'
}, [activeMeta, activeSectionId, apiSectionName])
return (
<TeacherShell
title="Students Class List"
titleClassName="text-center fw-bold"
loading={loading}
error={error}
>
<div className="d-flex flex-wrap justify-content-end gap-2 mb-3">
{activeSectionId != null ? (
<Link to="/app/teacher/exam-drafts" className="btn btn-success btn-sm">
Exam Drafts
</Link>
) : null}
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => reload()}>
Refresh
</button>
</div>
{!loading && raw != null && payload == null ? (
<div className="alert alert-warning">
Unexpected response shape.
<pre className="small mb-0 mt-2 bg-light p-2 rounded overflow-auto" style={{ maxHeight: 200 }}>
{JSON.stringify(raw, null, 2)}
</pre>
</div>
) : null}
{payload && payload.ok === false ? (
<div className="alert alert-danger">{String(payload.message ?? 'Unable to load class view.')}</div>
) : null}
{payload && payload.ok !== false ? (
<div className="teacher-class-view">
{classes.length === 0 ? (
<div className="alert alert-info">No classes are assigned to you for the current term.</div>
) : (
<>
<div className="text-center text-muted small mb-3 teacher-class-view__meta">
<div>
<span className="fw-semibold text-body">Teachers:</span> {formatList(namesForSection.mains)}
</div>
<div>
<span className="fw-semibold text-body">Teacher Assistants:</span>{' '}
{formatList(namesForSection.tas)}
</div>
<div>
<span className="fw-semibold text-body">Grade-Section:</span> {gradeSectionLabel}
</div>
</div>
{classes.length > 1 ? (
<div className="d-flex flex-wrap justify-content-center gap-2 mb-3">
{classes.map((c) => {
const id = Number(c.class_section_id ?? 0)
if (!id) return null
const active = activeSectionId != null && Number(activeSectionId) === id
return (
<Link
key={id}
to={`/app/teacher/class-view?class_section_id=${encodeURIComponent(String(id))}`}
className={`btn btn-sm ${active ? 'btn-primary' : 'btn-outline-primary'}`}
>
{c.class_section_name || `Section ${id}`}
</Link>
)
})}
</div>
) : null}
{students.length === 0 ? (
<p className="text-muted text-center mb-0">No students in this class for the current term.</p>
) : (
<div className="table-responsive shadow-sm rounded border bg-white">
<table className="table table-bordered table-sm align-middle mb-0 teacher-class-view__table">
<thead>
<tr>
<th scope="col" className="text-center text-nowrap">
#
</th>
<th scope="col">School ID</th>
<th scope="col">First Name</th>
<th scope="col">Last Name</th>
<th scope="col" className="text-center text-nowrap">
Age
</th>
<th scope="col" className="text-center">
New Student
</th>
<th scope="col" className="text-center">
Take Photo
</th>
<th scope="col" className="text-center">
Allergies
</th>
<th scope="col" className="text-center">
Medical Conditions
</th>
</tr>
</thead>
<tbody>
{students.map((row, idx) => {
const sid = row.student_id ?? idx
const allergies =
Array.isArray(row.allergies) && row.allergies.length > 0
? row.allergies
: row.allergies_text
? row.allergies_text.split(',').map((s) => s.trim())
: []
const medical =
Array.isArray(row.medical_conditions) && row.medical_conditions.length > 0
? row.medical_conditions
: row.medical_conditions_text
? row.medical_conditions_text.split(',').map((s) => s.trim())
: []
return (
<tr key={sid}>
<td className="text-center text-muted">{idx + 1}</td>
<td>{row.school_id != null && row.school_id !== '' ? String(row.school_id) : '—'}</td>
<td>{row.firstname ?? '—'}</td>
<td>{row.lastname ?? '—'}</td>
<td className="text-center">{row.age != null ? String(row.age) : '—'}</td>
<td className="text-center">
<NewStudentBadge isNew={isTruthyOne(row.is_new)} />
</td>
<td className="text-center">
<PhotoConsentBadge consent={isTruthyOne(row.photo_consent)} />
</td>
<td className="text-center">
<AllergiesCell allergies={allergies} />
</td>
<td className="text-center">
<MedicalCell conditions={medical} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
) : null}
</TeacherShell>
)
}
+368
View File
@@ -0,0 +1,368 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
fetchTeacherDashboard,
isTeacherDashboardApiEnabled,
type TeacherDashboardPayload,
} from '../../api/teacherSession'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
type TrashRow = { id: number; subject?: string; created_at?: string }
type TrashPayload = { trashedMessages?: TrashRow[] }
type MsgRow = { id: number; sender_id?: string | number; subject?: string; created_at?: string }
type InboxPayload = { receivedMessages?: MsgRow[] }
type SentPayload = { sentMessages?: MsgRow[] }
type TeacherMessagePayload = { message?: Record<string, unknown>; thread?: unknown[] }
/** Every `/app/teacher/*` route so nothing is hidden behind the slim top nav alone. */
const TEACHER_PAGE_GROUPS = [
{
heading: 'Overview',
links: [
{ label: 'Dashboard', to: '/app/teacher_dashboard' },
{ label: 'No classes (notice)', to: '/app/teacher/no-classes' },
{ label: 'Select semester', to: '/app/teacher/select-semester' },
],
},
{
heading: 'Classes & attendance',
links: [
{ label: 'Class list', to: '/app/teacher/class-view' },
{ label: 'Attendance', to: '/app/teacher/showupdate-attendance' },
{ label: 'Absence / vacation', to: '/app/teacher/absence-vacation' },
],
},
{
heading: 'Grades & coursework',
links: [
{ label: 'Scores', to: '/app/teacher/scores' },
{ label: 'Homework list', to: '/app/teacher/homework-list' },
{ label: 'Add homework', to: '/app/teacher/add-homework' },
{ label: 'Add quiz', to: '/app/teacher/add-quiz' },
{ label: 'Add quiz column', to: '/app/teacher/add-quiz-column-form' },
{ label: 'Add midterm exam', to: '/app/teacher/add-midterm-exam' },
{ label: 'Add final exam', to: '/app/teacher/add-final-exam' },
{ label: 'Add participation', to: '/app/teacher/add-participation' },
{ label: 'Add project', to: '/app/teacher/add-project' },
{ label: 'Teacher assignments', to: '/app/teacher/teacher-assignment' },
{ label: 'Exam drafts', to: '/app/teacher/exam-drafts' },
],
},
{
heading: 'Class progress',
links: [
{ label: 'Submit progress', to: '/app/teacher/class-progress-submit' },
{ label: 'Progress history', to: '/app/teacher/class-progress-history' },
{ label: 'View progress', to: '/app/teacher/class-progress-view' },
],
},
{
heading: 'Competitions',
links: [
{
label: 'Competition scores',
to: '/app/teacher/competition-scores',
hint: 'Score entry uses /app/teacher/competition-scores/:competitionId',
},
],
},
{
heading: 'Calendar',
links: [{ label: 'Calendar', to: '/app/teacher/calendar' }],
},
{
heading: 'Messages',
links: [
{ label: 'Inbox', to: '/app/teacher/inbox' },
{ label: 'Sent', to: '/app/teacher/sent' },
{ label: 'Drafts', to: '/app/teacher/drafts' },
{ label: 'Trash', to: '/app/teacher/trash' },
{
label: 'Message thread',
to: '/app/teacher/inbox',
hint: '/app/teacher/messages/:messageId — open “View” from inbox.',
},
],
},
{
heading: 'Help',
links: [
{ label: 'Contact us', to: '/app/teacher/teacher-contactus' },
{ label: 'Support', to: '/app/teacher/teacher-support' },
],
},
] as const
/** `landing_page/teacher_dashboard` — optional `GET /api/v1/teacher/dashboard` (JWT) plus full route index. */
export function TeacherDashboardPage() {
const apiEnabled = isTeacherDashboardApiEnabled()
const [dash, setDash] = useState<TeacherDashboardPayload | null>(null)
const [dashLoading, setDashLoading] = useState(apiEnabled)
useEffect(() => {
if (!apiEnabled) return
let cancelled = false
fetchTeacherDashboard()
.then((d) => {
if (!cancelled) setDash(d)
})
.catch(() => {
if (!cancelled) setDash(null)
})
.finally(() => {
if (!cancelled) setDashLoading(false)
})
return () => {
cancelled = true
}
}, [apiEnabled])
const termLine =
dash?.school_year || dash?.semester
? [dash.school_year, dash.semester].filter(Boolean).join(' · ')
: null
const defaultSubtitle =
'All teacher portal screens are listed below. The top bar shows shortcuts only.'
return (
<TeacherShell
title="Teacher Dashboard"
subtitle={defaultSubtitle}
flash={dash?.welcome?.trim() ? String(dash.welcome) : undefined}
>
{dashLoading ? <p className="text-muted small mb-3">Loading dashboard</p> : null}
{termLine ? <p className="text-muted small mb-2">{termLine}</p> : null}
{dash?.counts && Object.keys(dash.counts).length > 0 ? (
<div className="row g-2 mb-4">
{Object.entries(dash.counts).map(([k, v]) => (
<div key={k} className="col-6 col-md-4 col-lg-2">
<div className="border rounded p-2 text-center small">
<div className="text-muted text-uppercase" style={{ fontSize: '0.7rem' }}>
{k.replace(/_/g, ' ')}
</div>
<div className="fw-semibold">{v != null && v !== '' ? String(v) : '—'}</div>
</div>
</div>
))}
</div>
) : null}
{dash?.announcements && dash.announcements.length > 0 ? (
<div className="mb-4">
{dash.announcements.map((a, i) => (
<div key={i} className="alert alert-light border mb-2">
{a.title ? <div className="fw-semibold">{a.title}</div> : null}
{a.body ? <div className="small mt-1">{a.body}</div> : null}
</div>
))}
</div>
) : null}
<p className="small text-muted mb-4">
CI view browser:{' '}
<Link to="/app/browse">/app/browse</Link> (registry links now map competition score views to this area).
</p>
{TEACHER_PAGE_GROUPS.map((group) => (
<section key={group.heading} className="mb-4" id={`teacher-${group.heading.replace(/\s+/g, '-').toLowerCase()}`}>
<h2 className="h6 text-uppercase text-muted mb-2">{group.heading}</h2>
<div className="row g-2">
{group.links.map((item) => (
<div className="col-6 col-md-4 col-lg-3" key={`${item.to}-${item.label}`}>
<Link className="btn btn-outline-primary w-100" to={item.to}>
{item.label}
</Link>
{'hint' in item && item.hint ? (
<p className="small text-muted mt-1 mb-0">{item.hint}</p>
) : null}
</div>
))}
</div>
</section>
))}
</TeacherShell>
)
}
/** `teacher/no_classes.php` */
export function TeacherNoClassesPage() {
const { data, loading, error } = useTeacherResource<{ message?: string }>('/no-classes')
const msg = data?.message
return (
<TeacherShell title="Classes" loading={loading} error={error} flash={msg}>
<p className="text-muted mb-0">If you believe this is an error, contact the school office.</p>
</TeacherShell>
)
}
/** `teacher/select_semester.php` */
export function TeacherSelectSemesterPage() {
const { data, loading, error } = useTeacherResource<{
invalidChoice?: string
fallPath?: string
springPath?: string
}>('/select-semester')
const fallTo = data?.fallPath ?? '/app/teacher/scores?semester=Fall'
const springTo = data?.springPath ?? '/app/teacher/scores?semester=Spring'
return (
<TeacherShell title="Choose a semester" loading={loading} error={error}>
<div className="row justify-content-center">
<div className="col-md-8 col-lg-6">
<div className="card shadow-sm border-0">
<div className="card-body text-center">
<p className="text-muted mb-4">
Before viewing scores, pick the semester you want to work with.
</p>
{data?.invalidChoice ? (
<div className="alert alert-warning">
<strong>Note:</strong> &quot;{data.invalidChoice}&quot; is not a valid semester selection.
</div>
) : null}
<div className="d-flex flex-wrap justify-content-center gap-3">
<Link to={fallTo} className="btn btn-primary btn-lg px-4">
Fall
</Link>
<Link to={springTo} className="btn btn-outline-primary btn-lg px-4">
Spring
</Link>
</div>
</div>
</div>
</div>
</div>
</TeacherShell>
)
}
/** `teacher/trash.php` */
export function TeacherTrashPage() {
const { data, loading, error } = useTeacherResource<TrashPayload>('/trash')
const rows = data?.trashedMessages ?? []
return (
<TeacherShell title="Trash" loading={loading} error={error}>
{rows.length === 0 ? (
<p>No messages found in trash.</p>
) : (
<div className="table-responsive">
<table className="table table-striped">
<thead>
<tr>
<th>Subject</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((m) => (
<tr key={m.id}>
<td>{m.subject ?? '—'}</td>
<td>{m.created_at ?? ''}</td>
<td>
<span className="text-muted small">Restore / delete via API when wired.</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TeacherShell>
)
}
/** `teacher/inbox.php` */
export function TeacherInboxPage() {
const { data, loading, error } = useTeacherResource<InboxPayload>('/inbox')
const rows = data?.receivedMessages ?? []
return (
<TeacherShell title="Inbox" loading={loading} error={error}>
{rows.length === 0 ? (
<p>No messages found.</p>
) : (
<div className="table-responsive">
<table className="table table-striped">
<thead>
<tr>
<th>From</th>
<th>Subject</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((m) => (
<tr key={m.id}>
<td>{String(m.sender_id ?? '')}</td>
<td>{m.subject ?? ''}</td>
<td>{m.created_at ?? ''}</td>
<td>
<Link to={`/app/teacher/messages/${m.id}`} className="me-2">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TeacherShell>
)
}
/** `teacher/sent.php` */
export function TeacherSentPage() {
const { data, loading, error } = useTeacherResource<SentPayload>('/sent')
const rows = data?.sentMessages ?? []
return (
<TeacherShell title="Sent" loading={loading} error={error}>
{rows.length === 0 ? (
<p>No sent messages.</p>
) : (
<div className="table-responsive">
<table className="table table-striped">
<thead>
<tr>
<th>To</th>
<th>Subject</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{rows.map((m) => (
<tr key={m.id}>
<td>{String(m.sender_id ?? '')}</td>
<td>{m.subject ?? ''}</td>
<td>{m.created_at ?? ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TeacherShell>
)
}
/** `teacher/teacher_message.php` — thread payload from `/api/v1/teacher/messages/:id`. */
export function TeacherMessagePage() {
const { messageId } = useParams()
const path = messageId ? `/messages/${messageId}` : '/message'
const { data, loading, error } = useTeacherResource<TeacherMessagePayload>(path)
return (
<TeacherShell title="Message" loading={loading} error={error}>
<pre className="small bg-light p-3 rounded overflow-auto" style={{ maxHeight: 420 }}>
{data ? JSON.stringify(data, null, 2) : null}
</pre>
</TeacherShell>
)
}
+3
View File
@@ -0,0 +1,3 @@
/** Barrel for CodeIgniter `Views/teacher/*` React parity screens. */
export * from './TeacherCorePages'
export * from './TeacherRestPages'
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
/** Shared wrapper for teacher parity screens (CI `Views/teacher/*`). */
export function TeacherShell({
title,
subtitle,
titleClassName,
subtitleClassName,
loading,
error,
flash,
children,
}: {
title: string
subtitle?: string
titleClassName?: string
subtitleClassName?: string
loading?: boolean
error?: string | null
flash?: string | null
children: ReactNode
}) {
return (
<div className="teacher-page-wrap w-100 py-3 px-2">
<div className="mb-3">
<h1 className={`h3 mb-1 ${titleClassName ?? ''}`.trim()}>{title}</h1>
{subtitle ? (
<p className={`text-muted mb-0 ${subtitleClassName ?? ''}`.trim()}>{subtitle}</p>
) : null}
</div>
{flash ? (
<div className="alert alert-info" role="alert">
{flash}
</div>
) : null}
{error ? (
<div className="alert alert-danger" role="alert">
{error}
</div>
) : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading && children}
</div>
)
}
@@ -0,0 +1,829 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ApiHttpError, apiFetch } from '../../api/http'
import { useAuth } from '../../auth/AuthProvider'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
export type TeacherAttendanceFormStudentRow = {
student?: Record<string, unknown>
sunday_statuses?: Record<string, string | null | undefined>
today?: string | null
today_reason?: string | null
locked?: boolean
locked_source?: string | null
recent_three?: Array<{ date?: string; status?: string }>
}
export type TeacherAttendanceFormTeacherRow = {
teacher_id?: number
position?: string
name?: string
sunday_statuses?: Record<string, string | null | undefined>
today?: string | null
today_reason?: string | null
}
export type TeacherAttendanceFormPayload = {
teacher_name?: string | null
students?: TeacherAttendanceFormStudentRow[]
teachers?: TeacherAttendanceFormTeacherRow[]
class_section_id?: number | null
sunday_dates?: string[]
current_sunday?: string | null
enable_attendance?: number | null
can_edit?: boolean
class_id?: number | null
no_school_days?: Record<string, { title?: string; description?: string }>
today?: string | null
locked_by_student?: Record<
string,
{ locked?: boolean; source?: string; reason?: string; status?: string | null }
>
}
type PlaValue = '' | 'present' | 'late' | 'absent'
function formatShortDate(iso: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
if (m) return `${m[2]}/${m[3]}/${m[1]}`
return iso
}
/** Calendar day for comparisons (`Y-m-d`), tolerating ISO datetimes from the API. */
function normalizeDateKey(s: string | null | undefined): string {
if (s == null) return ''
const t = String(s).trim()
const m = /^(\d{4}-\d{2}-\d{2})/.exec(t)
return m ? m[1] : t.slice(0, 10)
}
function isSameCalendarDate(a: string | null | undefined, b: string | null | undefined): boolean {
if (a == null || b == null || a === '' || b === '') return false
return normalizeDateKey(a) === normalizeDateKey(b)
}
function statusForDate(
map: Record<string, string | null | undefined> | undefined,
columnDate: string,
): string | null | undefined {
if (!map) return undefined
const want = normalizeDateKey(columnDate)
for (const [k, v] of Object.entries(map)) {
if (normalizeDateKey(k) === want) return v
}
return undefined
}
function pickNoSchool(
map: Record<string, { title?: string; description?: string }> | undefined,
columnDate: string,
): { title?: string; description?: string } | undefined {
if (!map) return undefined
const want = normalizeDateKey(columnDate)
for (const k of Object.keys(map)) {
if (normalizeDateKey(k) === want) return map[k]
}
return undefined
}
/** Matches backend intent: entry allowed unless explicitly disabled. */
function attendanceEntryEnabled(payload: TeacherAttendanceFormPayload): boolean {
if (payload.can_edit === false) return false
return Number(payload.enable_attendance) === 1
}
function unwrapTeacherAttendanceForm(raw: unknown): TeacherAttendanceFormPayload | null {
if (raw == null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
const inner = o.data
if (inner != null && typeof inner === 'object' && !Array.isArray(inner)) {
return inner as TeacherAttendanceFormPayload
}
return raw as TeacherAttendanceFormPayload
}
function studentDisplayName(student: Record<string, unknown> | undefined): string {
if (!student) return '—'
const fn = String(student.firstname ?? '').trim()
const ln = String(student.lastname ?? '').trim()
const combined = `${fn} ${ln}`.trim()
return combined || `Student #${String(student.id ?? '')}`
}
/** Laravel / JSON may nest under `student`, `attributes`, or flatten the row. */
function resolveStudentRecord(row: TeacherAttendanceFormStudentRow): Record<string, unknown> | undefined {
const nested = row.student
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
const n = nested as Record<string, unknown>
if (n.id != null) return n
const attrs = n.attributes
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
const a = attrs as Record<string, unknown>
if (a.id != null) return a
}
}
const flat = row as unknown as Record<string, unknown>
if (
flat.id != null &&
(flat.firstname != null || flat.lastname != null || flat.school_id != null)
) {
return flat
}
return undefined
}
/** API usually sends an object keyed by date; tolerate a [{ date, status }] list. */
function keyedSundayStatuses(raw: unknown): Record<string, string | null | undefined> {
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, string | null | undefined>
}
if (Array.isArray(raw)) {
const out: Record<string, string | null | undefined> = {}
for (const item of raw) {
if (!item || typeof item !== 'object') continue
const o = item as Record<string, unknown>
const dk = o.date != null ? normalizeDateKey(String(o.date)) : ''
if (!dk) continue
out[dk] = o.status != null ? String(o.status) : undefined
}
return out
}
return {}
}
/** Map API / legacy values to canonical status for `<select>` and submit. */
function normalizePlaStatus(raw: string | null | undefined): PlaValue {
if (raw == null || raw === '') return ''
const s = String(raw).toLowerCase().trim()
if (s === 'n/a') return ''
if (s === 'present' || s === 'p') return 'present'
if (s === 'absent' || s === 'a') return 'absent'
if (s === 'late' || s === 'l' || s === 'tardy' || s === 't') return 'late'
return ''
}
function isConcretePla(v: PlaValue | undefined): v is 'present' | 'late' | 'absent' {
return v === 'present' || v === 'late' || v === 'absent'
}
/** Prefer an explicit P/L/A from local state; otherwise API grid / `today` (fixes `'' ?? fromApi` staying empty). */
function effectiveStudentPla(
row: TeacherAttendanceFormStudentRow,
id: number,
currentSunday: string,
studentPla: Record<number, PlaValue>,
): PlaValue {
const fromApi = normalizePlaStatus(
statusForDate(keyedSundayStatuses(row.sunday_statuses), currentSunday) ?? row.today,
)
if (row.locked) return fromApi
const ui = studentPla[id]
return isConcretePla(ui) ? ui : fromApi
}
function effectiveTeacherPla(
t: TeacherAttendanceFormTeacherRow,
tid: number,
currentSunday: string,
teacherPla: Record<number, PlaValue>,
): PlaValue {
const fromApi = normalizePlaStatus(
statusForDate(keyedSundayStatuses(t.sunday_statuses), currentSunday) ?? t.today,
)
const ui = teacherPla[tid]
return isConcretePla(ui) ? ui : fromApi
}
function plaBadgeClass(v: PlaValue): string {
if (v === 'present') return 'text-bg-success'
if (v === 'late') return 'text-bg-warning text-dark'
if (v === 'absent') return 'text-bg-danger'
return 'text-bg-secondary'
}
function plaLetter(v: PlaValue): string {
if (v === 'present') return 'P'
if (v === 'late') return 'L'
if (v === 'absent') return 'A'
return ''
}
function statusBadgeClass(status: string | null | undefined): string {
const v = normalizePlaStatus(status)
if (v) return plaBadgeClass(v)
const s = String(status ?? '').toLowerCase()
if (s === 'n/a') return 'text-bg-light text-dark border'
if (s.includes('excused')) return 'text-bg-info'
return 'text-bg-secondary'
}
function StatusCell({
status,
noSchool,
}: {
status: string | null | undefined
noSchool?: { title?: string; description?: string }
}) {
if (noSchool) {
const tip = [noSchool.title, noSchool.description].filter(Boolean).join(' — ')
return (
<span className="badge text-bg-secondary" title={tip || undefined}>
No school
</span>
)
}
if (status == null || status === '') {
return <span className="text-muted"></span>
}
const v = normalizePlaStatus(status)
if (v) {
return (
<span className={`badge ${plaBadgeClass(v)}`} title={v}>
{plaLetter(v)}
</span>
)
}
return <span className={`badge ${statusBadgeClass(status)}`}>{String(status)}</span>
}
const PLA_OPTIONS: Array<{ value: PlaValue; label: string }> = [
{ value: '', label: '—' },
{ value: 'present', label: 'P' },
{ value: 'late', label: 'L' },
{ value: 'absent', label: 'A' },
]
function AttendancePlaSelect({
value,
onChange,
disabled,
ariaLabel,
}: {
value: PlaValue
onChange: (v: PlaValue) => void
disabled?: boolean
ariaLabel: string
}) {
const tone =
value === 'present'
? 'border-success bg-success bg-opacity-10'
: value === 'late'
? 'border-warning bg-warning bg-opacity-25'
: value === 'absent'
? 'border-danger bg-danger bg-opacity-10'
: 'border-secondary bg-light'
return (
<div className={`rounded border ${tone}`} style={{ minWidth: '3.25rem' }}>
<select
className="form-select form-select-sm border-0 bg-transparent text-center fw-semibold py-1"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value as PlaValue)}
aria-label={ariaLabel}
>
{PLA_OPTIONS.map((o) => (
<option key={o.value || 'empty'} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
)
}
function RecentThreeCell({ rows }: { rows: TeacherAttendanceFormStudentRow['recent_three'] }) {
if (!rows || rows.length === 0) return <span className="text-muted"></span>
return (
<ul className="list-unstyled small mb-0">
{rows.map((r, i) => (
<li key={`${r.date ?? i}-${r.status ?? ''}`}>
{r.date ? formatShortDate(r.date) : '—'}: {r.status ?? '—'}
</li>
))}
</ul>
)
}
function canEditColumn(
payload: TeacherAttendanceFormPayload,
columnDate: string,
resolvedCurrentSunday: string,
opts?: { noSchool?: boolean; studentLocked?: boolean },
): boolean {
if (!attendanceEntryEnabled(payload)) return false
if (!resolvedCurrentSunday || !isSameCalendarDate(columnDate, resolvedCurrentSunday)) return false
if (opts?.noSchool) return false
if (opts?.studentLocked) return false
return true
}
/** Reason text field follows the same Today rules as P/L/A (not locked / no school / entry enabled). */
function reasonFieldEnabled(
payload: TeacherAttendanceFormPayload,
currentSunday: string,
noSchoolDays: Record<string, { title?: string; description?: string }>,
studentLocked?: boolean,
): boolean {
if (!attendanceEntryEnabled(payload)) return false
if (!currentSunday || pickNoSchool(noSchoolDays, currentSunday)) return false
if (studentLocked) return false
return true
}
function AttendanceReasonInput({
value,
onChange,
disabled,
id,
label,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
id: string
label: string
}) {
return (
<input
id={id}
type="text"
className="form-control form-control-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={disabled ? undefined : 'Optional'}
maxLength={255}
aria-label={label}
/>
)
}
function AttendanceTables({
payload,
noSchoolDays,
reload,
}: {
payload: TeacherAttendanceFormPayload
noSchoolDays: Record<string, { title?: string; description?: string }>
reload: () => void
}) {
const dates = payload.sunday_dates ?? []
const currentSunday =
payload.current_sunday != null && String(payload.current_sunday).trim() !== ''
? String(payload.current_sunday)
: dates.length >= 3
? String(dates[2])
: dates.length > 0
? String(dates[dates.length - 1])
: ''
const students = payload.students ?? []
const teachers = payload.teachers ?? []
const historicalDates = useMemo(
() => dates.filter((d) => !isSameCalendarDate(d, currentSunday)),
[dates, currentSunday],
)
const [studentPla, setStudentPla] = useState<Record<number, PlaValue>>({})
const [teacherPla, setTeacherPla] = useState<Record<number, PlaValue>>({})
const [studentReason, setStudentReason] = useState<Record<number, string>>({})
const [teacherReason, setTeacherReason] = useState<Record<number, string>>({})
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saveFlash, setSaveFlash] = useState<string | null>(null)
useEffect(() => {
if (!payload || !currentSunday) return
const stu = payload.students ?? []
const tea = payload.teachers ?? []
const nextS: Record<number, PlaValue> = {}
for (const row of stu) {
const rec = resolveStudentRecord(row)
const id = Number(rec?.id ?? 0)
if (!id) continue
nextS[id] = normalizePlaStatus(
statusForDate(keyedSundayStatuses(row.sunday_statuses), currentSunday) ?? row.today,
)
}
setStudentPla(nextS)
const nextSr: Record<number, string> = {}
for (const row of stu) {
const rec = resolveStudentRecord(row)
const id = Number(rec?.id ?? 0)
if (!id) continue
nextSr[id] = row.today_reason != null ? String(row.today_reason) : ''
}
setStudentReason(nextSr)
const nextT: Record<number, PlaValue> = {}
const nextTr: Record<number, string> = {}
for (const t of tea) {
const tid = Number(t.teacher_id ?? 0)
if (!tid) continue
nextT[tid] = normalizePlaStatus(
statusForDate(keyedSundayStatuses(t.sunday_statuses), currentSunday) ?? t.today,
)
nextTr[tid] = t.today_reason != null ? String(t.today_reason) : ''
}
setTeacherPla(nextT)
setTeacherReason(nextTr)
}, [payload, currentSunday])
const allFilledForSubmit = useMemo(() => {
for (const row of students) {
const id = Number(resolveStudentRecord(row)?.id ?? 0)
if (!id) continue
if (row.locked) continue
if (effectiveStudentPla(row, id, currentSunday, studentPla) === '') return false
}
for (const t of teachers) {
const tid = Number(t.teacher_id ?? 0)
if (!tid) continue
if (effectiveTeacherPla(t, tid, currentSunday, teacherPla) === '') return false
}
return students.length > 0 && teachers.length > 0
}, [students, teachers, studentPla, teacherPla, currentSunday])
const submitDateKey = normalizeDateKey(currentSunday)
const canSubmitForm =
attendanceEntryEnabled(payload) &&
!!submitDateKey &&
!pickNoSchool(noSchoolDays, currentSunday) &&
allFilledForSubmit
const handleSave = useCallback(async () => {
setSubmitError(null)
setSaveFlash(null)
if (!canSubmitForm || !submitDateKey) return
const sectionId = payload.class_section_id
if (sectionId == null || Number(sectionId) <= 0) {
setSubmitError('Missing class section.')
return
}
const attendance = students
.map((row) => {
const id = Number(resolveStudentRecord(row)?.id ?? 0)
if (!id) return null
const chosen = effectiveStudentPla(row, id, currentSunday, studentPla)
if (!chosen) return null
const schoolId = resolveStudentRecord(row)?.school_id
const reasonText = row.locked
? row.today_reason != null
? String(row.today_reason)
: undefined
: studentReason[id]?.trim() || undefined
return {
student_id: id,
school_id: schoolId != null && schoolId !== '' ? String(schoolId) : undefined,
status: chosen,
reason: reasonText,
is_reported: 'no',
}
})
.filter(Boolean) as Array<{
student_id: number
school_id?: string
status: 'present' | 'late' | 'absent'
reason?: string
is_reported: string
}>
if (attendance.length === 0) {
setSubmitError('No student rows to save.')
return
}
const teachersPayload: Record<string, { status: string; reason?: string | null }> = {}
for (const t of teachers) {
const tid = Number(t.teacher_id ?? 0)
if (!tid) continue
const st = effectiveTeacherPla(t, tid, currentSunday, teacherPla)
if (!st) {
setSubmitError('Please set attendance (P / L / A) for all staff.')
return
}
const tr = teacherReason[tid]?.trim()
teachersPayload[String(tid)] = {
status: st,
...(tr ? { reason: tr } : {}),
}
}
if (Object.keys(teachersPayload).length === 0) {
setSubmitError('No staff rows to save.')
return
}
setSubmitting(true)
try {
await apiFetch<{ ok?: boolean; message?: string }>('/api/v1/attendance/teacher/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
class_section_id: sectionId,
class_id: payload.class_id ?? undefined,
date: submitDateKey,
attendance,
teachers: teachersPayload,
}),
})
setSaveFlash('Attendance submitted successfully.')
reload()
} catch (e: unknown) {
setSubmitError(e instanceof ApiHttpError ? e.message : 'Unable to save attendance.')
} finally {
setSubmitting(false)
}
}, [
canSubmitForm,
payload,
reload,
studentPla,
studentReason,
submitDateKey,
currentSunday,
teacherPla,
teacherReason,
students,
teachers,
])
return (
<div className="d-flex flex-column gap-4">
{saveFlash ? (
<div className="alert alert-success py-2 mb-0" role="status">
{saveFlash}
</div>
) : null}
{submitError ? (
<div className="alert alert-danger py-2 mb-0" role="alert">
{submitError}
</div>
) : null}
<section>
<h2 className="h5 mb-2">Staff attendance</h2>
{teachers.length === 0 ? (
<p className="text-muted mb-0">No staff rows returned for this section.</p>
) : (
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle">
<thead className="table-light">
<tr>
<th>Name</th>
<th>Role</th>
{historicalDates.map((d) => (
<th key={`th-staff-${d}`} className="text-center text-nowrap">
{formatShortDate(d)}
{pickNoSchool(noSchoolDays, d) ? (
<div className="fw-normal small text-muted">No school</div>
) : null}
</th>
))}
<th className="text-center text-nowrap">
Today
{currentSunday ? (
<div className="fw-normal small text-muted">{formatShortDate(currentSunday)}</div>
) : null}
{pickNoSchool(noSchoolDays, currentSunday) ? (
<div className="fw-normal small text-muted">No school</div>
) : null}
</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{teachers.map((t) => {
const tid = Number(t.teacher_id ?? 0)
return (
<tr key={t.teacher_id ?? t.name}>
<td>{t.name ?? '—'}</td>
<td className="text-capitalize">{t.position === 'ta' ? 'TA' : t.position ?? '—'}</td>
{historicalDates.map((d) => (
<td key={`${t.teacher_id}-${d}`} className="text-center">
<StatusCell
status={statusForDate(keyedSundayStatuses(t.sunday_statuses), d)}
noSchool={pickNoSchool(noSchoolDays, d)}
/>
</td>
))}
<td className="text-center">
{canEditColumn(payload, currentSunday, currentSunday, {
noSchool: !!pickNoSchool(noSchoolDays, currentSunday),
}) ? (
<div className="d-inline-flex justify-content-center">
<AttendancePlaSelect
value={teacherPla[tid] ?? ''}
onChange={(v) => setTeacherPla((prev) => ({ ...prev, [tid]: v }))}
ariaLabel={`Attendance for ${t.name ?? 'staff'} on Today`}
/>
</div>
) : (
<StatusCell
status={statusForDate(keyedSundayStatuses(t.sunday_statuses), currentSunday)}
noSchool={pickNoSchool(noSchoolDays, currentSunday)}
/>
)}
</td>
<td style={{ minWidth: '10rem' }}>
<AttendanceReasonInput
id={`staff-reason-${tid}`}
label={`Reason for ${t.name ?? 'staff'}`}
value={teacherReason[tid] ?? ''}
onChange={(next) => setTeacherReason((prev) => ({ ...prev, [tid]: next }))}
disabled={
!reasonFieldEnabled(payload, currentSunday, noSchoolDays, false)
}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
<section>
<h2 className="h5 mb-2">Student attendance</h2>
{students.length === 0 ? (
<p className="text-muted mb-0">No students in this class for the current term.</p>
) : (
<div className="table-responsive">
<table className="table table-bordered table-sm align-middle">
<thead className="table-light">
<tr>
<th>School ID</th>
<th>Name</th>
{historicalDates.map((d) => (
<th key={`th-stu-${d}`} className="text-center text-nowrap">
{formatShortDate(d)}
{pickNoSchool(noSchoolDays, d) ? (
<div className="fw-normal small text-muted">No school</div>
) : null}
</th>
))}
<th className="text-center text-nowrap">
Today
{currentSunday ? (
<div className="fw-normal small text-muted">{formatShortDate(currentSunday)}</div>
) : null}
{pickNoSchool(noSchoolDays, currentSunday) ? (
<div className="fw-normal small text-muted">No school</div>
) : null}
</th>
<th>Reason</th>
<th>Recent (last 3)</th>
</tr>
</thead>
<tbody>
{students.map((row, idx) => {
const st = resolveStudentRecord(row)
const sid = st?.id != null ? String(st.id) : `row-${idx}`
const id = Number(st?.id ?? 0)
const schoolId = st?.school_id != null ? String(st.school_id) : '—'
return (
<tr key={sid}>
<td>{schoolId}</td>
<td>{studentDisplayName(st)}</td>
{historicalDates.map((d) => (
<td key={`${sid}-${d}`} className="text-center">
<StatusCell
status={statusForDate(keyedSundayStatuses(row.sunday_statuses), d)}
noSchool={pickNoSchool(noSchoolDays, d)}
/>
</td>
))}
<td className="text-center">
{canEditColumn(payload, currentSunday, currentSunday, {
noSchool: !!pickNoSchool(noSchoolDays, currentSunday),
studentLocked: !!row.locked,
}) ? (
<div className="d-inline-flex justify-content-center">
<AttendancePlaSelect
value={studentPla[id] ?? ''}
onChange={(v) => setStudentPla((prev) => ({ ...prev, [id]: v }))}
ariaLabel={`Attendance for ${studentDisplayName(st)} on Today`}
/>
</div>
) : (
<StatusCell
status={statusForDate(keyedSundayStatuses(row.sunday_statuses), currentSunday)}
noSchool={pickNoSchool(noSchoolDays, currentSunday)}
/>
)}
</td>
<td style={{ minWidth: '10rem' }}>
<AttendanceReasonInput
id={`stu-reason-${id}`}
label={`Reason for ${studentDisplayName(st)}`}
value={studentReason[id] ?? ''}
onChange={(next) => setStudentReason((prev) => ({ ...prev, [id]: next }))}
disabled={
!reasonFieldEnabled(payload, currentSunday, noSchoolDays, !!row.locked)
}
/>
</td>
<td className="small">
<RecentThreeCell rows={row.recent_three} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
<div className="d-flex flex-wrap align-items-center gap-2 pt-1 border-top">
{attendanceEntryEnabled(payload) &&
currentSunday &&
!pickNoSchool(noSchoolDays, currentSunday) ? (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!canSubmitForm || submitting}
onClick={() => void handleSave()}
>
{submitting ? 'Saving…' : 'Submit attendance (Today)'}
</button>
) : null}
{!allFilledForSubmit &&
attendanceEntryEnabled(payload) &&
currentSunday &&
!pickNoSchool(noSchoolDays, currentSunday) ? (
<span className="small text-muted">Choose P, L, or A for every student and staff member for Today.</span>
) : null}
</div>
</div>
)
}
/** `teacher/showupdate_attendance.php` — grid + P/L/A for current Sunday; POST `/api/v1/attendance/teacher/submit`. */
export function TeacherShowupdateAttendancePage() {
const { user } = useAuth()
const [search] = useSearchParams()
const sectionId = search.get('class_section_id')?.trim() || (user?.class_section_id != null ? String(user.class_section_id) : '')
const path = useMemo(() => {
const q = sectionId ? `?class_section_id=${encodeURIComponent(sectionId)}` : ''
return `/showupdate-attendance${q}`
}, [sectionId])
const { data: raw, loading, error, reload } = useTeacherResource<unknown>(path)
const payload = useMemo(() => unwrapTeacherAttendanceForm(raw), [raw])
const noSchoolDays = payload?.no_school_days ?? {}
const subtitleParts: string[] = []
if (payload?.teacher_name) subtitleParts.push(`Logged in as ${payload.teacher_name}`)
if (payload?.class_section_id != null && payload.class_section_id > 0) {
subtitleParts.push(`Section ID ${payload.class_section_id}`)
}
if (payload?.current_sunday) {
subtitleParts.push(`Today ${formatShortDate(payload.current_sunday)}`)
}
return (
<TeacherShell
title="Attendance"
subtitle={subtitleParts.length ? subtitleParts.join(' · ') : undefined}
loading={loading}
error={error}
>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div className="small text-muted">
{Number(payload?.enable_attendance) === 1 ? (
<span>Attendance entry is enabled for the school.</span>
) : (
<span>Attendance entry is disabled in configuration.</span>
)}
{payload?.can_edit === false ? (
<span className="ms-1">You do not have edit access from this summary.</span>
) : null}
</div>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => reload()}>
Refresh
</button>
</div>
{!loading && raw != null && payload == null ? (
<div className="alert alert-warning">
Unexpected response shape. Raw payload (for debugging):
<pre className="small mb-0 mt-2 bg-light p-2 rounded overflow-auto" style={{ maxHeight: 240 }}>
{JSON.stringify(raw, null, 2)}
</pre>
</div>
) : null}
{payload ? <AttendanceTables payload={payload} noSchoolDays={noSchoolDays} reload={reload} /> : null}
</TeacherShell>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { ApiHttpError } from '../../api/http'
import { fetchTeacherJson } from '../../api/teacherSession'
export function useTeacherResource<T>(path: string): {
data: T | null
loading: boolean
error: string | null
reload: () => void
} {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tick, setTick] = useState(0)
useEffect(() => {
let cancelled = false
setLoading(true)
fetchTeacherJson<T>(path)
.then((d) => {
if (!cancelled) {
setData(d)
setError(null)
}
})
.catch((e: unknown) => {
if (!cancelled)
setError(e instanceof ApiHttpError ? e.message : 'Unable to load this page.')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [path, tick])
return {
data,
loading,
error,
reload: () => setTick((t) => t + 1),
}
}