376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|