Files
alrahma_web_client/src/pages/teacher/TeacherClassProgressHistoryPage.tsx
T
2026-04-25 00:00:10 -04:00

376 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}