fix teacher, parent and admin pages
This commit is contained in:
@@ -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 & 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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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> "{data.invalidChoice}" 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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user