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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user