Files
alrahma_web_client/src/pages/teacher/TeacherClassProgressSubmitPage.tsx
T
root b895c06dc1
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
fix parent pages and teachers and admin
2026-07-09 13:56:26 -04:00

615 lines
24 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 { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Link } 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 { data: raw, loading, error, reload } = useTeacherResource<unknown>('/class-progress-submit')
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 [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])
useEffect(() => {
if (classSectionId === '' && classes.length === 1) {
const only = Number(classes[0].class_section_id ?? 0)
if (only > 0) setClassSectionId(only)
}
}, [classes, classSectionId])
const canSubmit = useMemo(() => {
if (!weekStart) return false
if (!islamicSelectionValid(islamicRows)) return false
const covIslamic = (covered.islamic ?? '').trim()
if (covIslamic === '') return false
return true
}, [weekStart, 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) 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('week_start', weekStart)
if (defaults.school_year) fd.append('school_year', defaults.school_year)
if (defaults.semester) fd.append('semester', defaults.semester)
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,
weekStart,
defaults.school_year,
defaults.semester,
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}
{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 &amp; 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>
</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 week, Islamic unit/chapter, and material covered for Islamic Studies.
</span>
) : null}
</div>
</>
) : null}
</div>
) : null}
</TeacherShell>
)
}