fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
@@ -0,0 +1,375 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { formatProgressDate } from '../classProgress/classProgressUtils'
import { ClassProgressUnitDisplay } from '../classProgress/ClassProgressUnitDisplay'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
const STATUS_OPTIONS: Record<string, string> = {
on_track: 'On track',
slightly_behind: 'Slightly behind',
behind: 'Behind',
}
type Pagination = {
current_page?: number
last_page?: number
per_page?: number
total?: number
}
export type TeacherProgressReportRow = {
id?: number
class_section_id?: number
class_section_name?: string | null
week_start?: string | null
week_end?: string | null
subject?: string | null
unit_title?: string | null
covered?: string | null
homework?: string | null
status?: string | null
status_label?: string | null
teacher_name?: string | null
updated_at?: string | null
}
/** Laravel `JsonResource::collection()` often serializes as `{ data: [...] }` under `items`. */
function extractReportRows(rawItems: unknown): TeacherProgressReportRow[] {
if (rawItems == null) return []
if (Array.isArray(rawItems)) return rawItems as TeacherProgressReportRow[]
if (typeof rawItems === 'object' && 'data' in (rawItems as object)) {
const inner = (rawItems as { data: unknown }).data
if (Array.isArray(inner)) return inner as TeacherProgressReportRow[]
}
return []
}
function unwrapHistoryList(raw: unknown): { items: TeacherProgressReportRow[]; pagination?: Pagination } | null {
if (raw == null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
let data = (o.data ?? o) as Record<string, unknown>
// Rare: double-wrapped `data` from intermediaries
if (!('items' in data) && typeof data.data === 'object' && data.data !== null) {
const inner = data.data as Record<string, unknown>
if ('items' in inner) data = inner
}
if (!('items' in data)) return null
const rows = extractReportRows(data.items)
return {
items: rows,
pagination: data.pagination as Pagination | undefined,
}
}
/** Empty values (e.g. `week_start=`) break Laravel `date_format` validation on the index endpoint. */
function buildHistoryQueryString(qs: string): string {
const p = new URLSearchParams(qs || undefined)
const emptyKeys: string[] = []
p.forEach((v, k) => {
if (v === '' || v == null) emptyKeys.push(k)
})
emptyKeys.forEach((k) => p.delete(k))
if (!p.has('per_page')) p.set('per_page', '100')
if (!p.has('sort_by')) p.set('sort_by', 'week_start')
if (!p.has('sort_dir')) p.set('sort_dir', 'desc')
return p.toString()
}
function statusBadgeClass(status: string | null | undefined): string {
const s = String(status ?? '').toLowerCase()
if (s === 'on_track') return 'text-bg-success'
if (s === 'slightly_behind') return 'text-bg-warning text-dark'
if (s === 'behind') return 'text-bg-danger'
return 'text-bg-secondary'
}
function truncate(s: string | null | undefined, max: number): string {
const t = String(s ?? '').trim()
if (t.length <= max) return t
return `${t.slice(0, max - 1)}`
}
/** `GET /api/v1/teacher/class-progress-history` — paginated report rows for the logged-in teacher. */
export function TeacherClassProgressHistoryPage() {
const [search, setSearch] = useSearchParams()
const qs = search.toString()
const listPath = useMemo(() => {
return `/class-progress-history?${buildHistoryQueryString(qs)}`
}, [qs])
const { data: raw, loading, error, reload } = useTeacherResource<unknown>(listPath)
const parsed = useMemo(() => unwrapHistoryList(raw), [raw])
const items = parsed?.items ?? []
const pagination = parsed?.pagination
const [draftWeekStart, setDraftWeekStart] = useState(() => search.get('week_start') ?? '')
const [draftWeekEnd, setDraftWeekEnd] = useState(() => search.get('week_end') ?? '')
const [draftSection, setDraftSection] = useState(() => search.get('class_section_id') ?? '')
const [draftStatus, setDraftStatus] = useState(() => search.get('status') ?? '')
const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc')
useEffect(() => {
const p = new URLSearchParams(buildHistoryQueryString(qs))
setDraftWeekStart(p.get('week_start') ?? '')
setDraftWeekEnd(p.get('week_end') ?? '')
setDraftSection(p.get('class_section_id') ?? '')
setDraftStatus(p.get('status') ?? '')
setDraftSortDir(p.get('sort_dir') || 'desc')
}, [qs])
const sectionOptions = useMemo(() => {
const m = new Map<number, string>()
for (const r of items) {
const id = Number(r.class_section_id ?? 0)
if (!id) continue
const name = String(r.class_section_name ?? '').trim() || `Section ${id}`
if (!m.has(id)) m.set(id, name)
}
return [...m.entries()].sort((a, b) => a[0] - b[0])
}, [items])
function applyFilters(e: FormEvent) {
e.preventDefault()
const next = new URLSearchParams()
next.set('per_page', search.get('per_page') || '100')
next.set('sort_by', search.get('sort_by') || 'week_start')
next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc')
if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim())
if (draftWeekEnd.trim()) next.set('week_end', draftWeekEnd.trim())
if (draftSection.trim()) next.set('class_section_id', draftSection.trim())
if (draftStatus.trim()) next.set('status', draftStatus.trim())
next.delete('page')
setSearch(next, { replace: true })
}
function resetFilters() {
setDraftWeekStart('')
setDraftWeekEnd('')
setDraftSection('')
setDraftStatus('')
const next = new URLSearchParams()
next.set('per_page', '100')
next.set('sort_by', 'week_start')
next.set('sort_dir', 'desc')
setDraftSortDir('desc')
setSearch(next, { replace: true })
}
const currentPage = Math.max(1, Number(pagination?.current_page) || 1)
const lastPage = Math.max(1, Number(pagination?.last_page) || 1)
const total = pagination?.total != null ? Number(pagination.total) : items.length
function goPage(p: number) {
const next = new URLSearchParams(buildHistoryQueryString(qs))
if (p <= 1) next.delete('page')
else next.set('page', String(p))
setSearch(next, { replace: true })
}
return (
<TeacherShell
title="Class progress — history"
subtitle="Your submitted weekly progress reports."
loading={loading}
error={error}
>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<Link to="/app/teacher/class-progress-submit" className="btn btn-outline-primary btn-sm">
Submit progress
</Link>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => reload()}>
Refresh
</button>
</div>
{!loading && raw != null && parsed == null ? (
<div className="alert alert-warning">
Unexpected response shape.
<pre className="small mb-0 mt-2 bg-light p-2 rounded overflow-auto" style={{ maxHeight: 200 }}>
{JSON.stringify(raw, null, 2)}
</pre>
</div>
) : null}
<form className="card border mb-3" onSubmit={applyFilters}>
<div className="card-body py-3">
<div className="row g-2 align-items-end">
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-ws">
Week from
</label>
<input
id="hist-ws"
type="date"
className="form-control form-control-sm"
value={draftWeekStart}
onChange={(e) => setDraftWeekStart(e.target.value)}
/>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-we">
Week to
</label>
<input
id="hist-we"
type="date"
className="form-control form-control-sm"
value={draftWeekEnd}
onChange={(e) => setDraftWeekEnd(e.target.value)}
/>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-sec">
Section
</label>
<select
id="hist-sec"
className="form-select form-select-sm"
value={draftSection}
onChange={(e) => setDraftSection(e.target.value)}
>
<option value="">All</option>
{sectionOptions.map(([id, name]) => (
<option key={id} value={String(id)}>
{name}
</option>
))}
</select>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-st">
Status
</label>
<select
id="hist-st"
className="form-select form-select-sm"
value={draftStatus}
onChange={(e) => setDraftStatus(e.target.value)}
>
<option value="">All</option>
{Object.entries(STATUS_OPTIONS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-sort">
Week order
</label>
<select
id="hist-sort"
className="form-select form-select-sm"
value={draftSortDir}
onChange={(e) => setDraftSortDir(e.target.value)}
>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
<div className="col-md-12 col-lg-4 d-flex flex-wrap gap-2">
<button type="submit" className="btn btn-primary btn-sm">
Apply filters
</button>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={resetFilters}>
Reset
</button>
</div>
</div>
</div>
</form>
{items.length === 0 && !loading ? (
<p className="text-muted mb-0">No progress reports match these filters.</p>
) : null}
{items.length > 0 ? (
<>
<div className="table-responsive border rounded bg-white shadow-sm">
<table className="table table-sm table-striped table-hover align-middle mb-0">
<thead className="table-light">
<tr>
<th scope="col">Week</th>
<th scope="col">Section</th>
<th scope="col">Subject</th>
<th scope="col">Status</th>
<th scope="col">Unit</th>
<th scope="col">Covered</th>
<th scope="col">Homework</th>
<th scope="col" className="text-nowrap">
Updated
</th>
</tr>
</thead>
<tbody>
{items.map((row) => {
const id = Number(row.id ?? 0)
const week =
row.week_start || row.week_end
? `${formatProgressDate(row.week_start)} ${formatProgressDate(row.week_end)}`
: '—'
const section = String(row.class_section_name ?? '').trim() || `ID ${row.class_section_id ?? '—'}`
const subject = String(row.subject ?? '—')
const isQuran = subject.toLowerCase().includes('quran')
const st = row.status_label ?? STATUS_OPTIONS[String(row.status ?? '')] ?? row.status ?? '—'
return (
<tr key={id || `${week}-${subject}`}>
<td className="text-nowrap small">{week}</td>
<td className="small">{section}</td>
<td className="small fw-semibold">{subject}</td>
<td>
<span className={`badge rounded-pill ${statusBadgeClass(row.status)}`}>{st}</span>
</td>
<td className="small">
<ClassProgressUnitDisplay unitTitle={row.unit_title} isQuran={isQuran} compact />
</td>
<td className="small text-muted" title={row.covered ?? undefined}>
{truncate(row.covered, 120) || '—'}
</td>
<td className="small text-muted" title={row.homework ?? undefined}>
{truncate(row.homework, 80) || '—'}
</td>
<td className="small text-nowrap text-muted">
{formatProgressDate(row.updated_at, true)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{lastPage > 1 ? (
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mt-3">
<span className="small text-muted">
Page {currentPage} of {lastPage} ({total} total)
</span>
<div className="btn-group btn-group-sm">
<button
type="button"
className="btn btn-outline-secondary"
disabled={currentPage <= 1}
onClick={() => goPage(currentPage - 1)}
>
Previous
</button>
<button
type="button"
className="btn btn-outline-secondary"
disabled={currentPage >= lastPage}
onClick={() => goPage(currentPage + 1)}
>
Next
</button>
</div>
</div>
) : null}
</>
) : null}
</TeacherShell>
)
}