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
+349
View File
@@ -0,0 +1,349 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session'
import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types'
function adjustQty(input: HTMLInputElement, delta: number) {
const curr = parseInt(input.value, 10) || 0
input.value = String(curr + delta)
}
function defaultSchoolYear() {
const y = new Date().getFullYear()
return `${y}-${y + 1}`
}
export function ClassPrepListPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? defaultSchoolYear()
const semester = searchParams.get('semester') ?? ''
const [data, setData] = useState<ClassPrepIndexResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [savingId, setSavingId] = useState<string | null>(null)
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const yearOptions = useMemo(() => {
const out: string[] = []
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
return out
}, [])
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetchClassPrepIndex({
school_year: schoolYear,
semester: semester || undefined,
})
.then((d) => {
if (!cancelled) setData(d)
})
.catch((e: unknown) => {
if (!cancelled)
setError(e instanceof ApiHttpError ? e.message : 'Failed to load class preparation data.')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [schoolYear, semester])
const onFilterSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const sy = String(fd.get('school_year') ?? '')
const sem = String(fd.get('semester') ?? '')
const next = new URLSearchParams()
if (sy) next.set('school_year', sy)
if (sem) next.set('semester', sem)
setSearchParams(next)
},
[setSearchParams],
)
async function onSaveAdjustment(prep: ClassPrepSectionRow, form: HTMLFormElement) {
setSavingId(String(prep.class_section_id))
setSaveMsg(null)
const fd = new FormData(form)
const adjustments: Record<string, number> = {}
fd.forEach((v, k) => {
if (k.startsWith('adj__')) {
const name = k.slice(5)
adjustments[name] = parseInt(String(v), 10) || 0
}
})
try {
await saveClassPrepAdjustment({
class_section_id: prep.class_section_id,
school_year: schoolYear,
semester: semester || undefined,
adjustments,
})
setSaveMsg('Saved.')
const fresh = await fetchClassPrepIndex({
school_year: schoolYear,
semester: semester || undefined,
})
setData(fresh)
} catch (e: unknown) {
setSaveMsg(e instanceof ApiHttpError ? e.message : 'Save failed.')
} finally {
setSavingId(null)
}
}
const printBase = `/app/administrator/class-prep/print`
const semQuery = semester ? `?semester=${encodeURIComponent(semester)}` : ''
return (
<div className="container-fluid">
<div className="wrapper">
<h2 className="text-center mt-4 mb-3">Class Preparation</h2>
<form onSubmit={onFilterSubmit} className="mb-4 d-flex align-items-center gap-2 flex-wrap">
<label htmlFor="school_year" className="form-label mb-0">
School Year:
</label>
<select
name="school_year"
id="school_year"
className="form-select form-select-sm w-auto"
defaultValue={schoolYear}
>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<label htmlFor="semester" className="form-label mb-0">
Semester:
</label>
<select
name="semester"
id="semester"
className="form-select form-select-sm w-auto"
defaultValue={semester}
>
<option value=""></option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
<button type="submit" className="btn btn-sm btn-primary">
Calculate Items
</button>
</form>
{loading ? (
<p className="text-muted">Loading</p>
) : error ? (
<div className="alert alert-danger">{error}</div>
) : null}
{saveMsg ? (
<div className="alert alert-info py-2" role="status">
{saveMsg}
</div>
) : null}
<div className="row g-3">
{(data?.prep_results ?? []).map((prep) => {
const adjMap = prep.adjustments ?? {}
return (
<div key={String(prep.class_section_id)} className="col-12 col-lg-6">
<div className="card shadow-sm border-0 h-100 classprep-card">
<div className="card-header d-flex justify-content-between align-items-center bg-light border-0">
<div>
<div className="fw-semibold mb-1">{prep.class_section}</div>
{prep.needs_print ? (
<span className="badge bg-danger">Updated since last print</span>
) : (
<span className="badge bg-success">Up-to-date</span>
)}
{prep.last_printed_at ? (
<small className="text-muted ms-2">Last printed: {prep.last_printed_at}</small>
) : null}
</div>
<div className="small text-muted text-end">
Students:
<br />
<span className="badge bg-secondary">{prep.student_count}</span>
</div>
</div>
<div className="card-body">
<div className="row g-3">
<div className="col-12 col-md-7">
<div className="fw-semibold mb-2">Prep Items</div>
<ul className="list-group list-group-flush small border rounded overflow-hidden">
{Object.entries(prep.prep_items ?? {}).map(([item, qty]) => (
<li
key={item}
className={`list-group-item d-flex justify-content-between align-items-center ${
Number(qty) === 0 ? 'text-muted classprep-muted-soft' : ''
}`}
>
<span>{item}</span>
<span
className={`badge rounded-pill ${
Number(qty) > 0 ? 'bg-primary' : 'bg-secondary'
}`}
>
{Number(qty)}
</span>
</li>
))}
</ul>
</div>
<div className="col-12 col-md-5">
<div className="fw-semibold mb-2">Adjustments (Tables)</div>
<form
className="d-grid gap-2"
onSubmit={(e) => {
e.preventDefault()
void onSaveAdjustment(prep, e.currentTarget)
}}
>
{Object.entries(adjMap).map(([item, value]) => (
<div key={item}>
<label className="form-label mb-1 small text-muted d-block">{item}</label>
<div className="input-group input-group-sm">
<button
type="button"
className="btn btn-outline-secondary"
onClick={(ev) => {
const input = ev.currentTarget.parentElement?.querySelector(
'input[type="number"]',
) as HTMLInputElement | null
if (input) adjustQty(input, -1)
}}
aria-label={`Decrease ${item}`}
>
</button>
<input
type="number"
className="form-control text-center"
name={`adj__${item}`}
defaultValue={Number(value)}
readOnly
/>
<button
type="button"
className="btn btn-outline-secondary"
onClick={(ev) => {
const input = ev.currentTarget.parentElement?.querySelector(
'input[type="number"]',
) as HTMLInputElement | null
if (input) adjustQty(input, 1)
}}
aria-label={`Increase ${item}`}
>
+
</button>
</div>
</div>
))}
<div className="d-flex justify-content-end">
<button
type="submit"
className="btn btn-sm btn-primary"
disabled={savingId === String(prep.class_section_id)}
>
{savingId === String(prep.class_section_id) ? 'Saving…' : 'Save'}
</button>
</div>
</form>
</div>
</div>
</div>
<div className="card-footer bg-transparent border-0 d-flex justify-content-end">
<Link
to={`${printBase}/${encodeURIComponent(String(prep.class_section_id))}/${encodeURIComponent(schoolYear)}${semQuery}`}
className={`btn btn-lg ${prep.needs_print ? 'btn-danger' : 'btn-success'}`}
target="_blank"
rel="noopener noreferrer"
>
Print
</Link>
</div>
</div>
</div>
)
})}
</div>
<div className="mt-5">
<h5 className="mb-3">Totals for All Used Items</h5>
<div className="table-responsive">
<table className="table table-sm table-bordered align-middle">
<thead className="table-light">
<tr>
<th>Item</th>
<th className="text-end">Total Needed</th>
<th className="text-end">In Inventory</th>
<th className="text-end">Short By</th>
</tr>
</thead>
<tbody>
{Object.entries(data?.total_needed ?? {}).map(([item, needed]) => {
const have = Number((data?.available ?? {})[item] ?? 0)
const short = Math.max(0, Number(needed) - have)
return (
<tr key={item} className={short > 0 ? 'table-warning' : ''}>
<td>{item}</td>
<td className="text-end">{Number(needed)}</td>
<td className="text-end">{have}</td>
<td
className={`text-end ${short > 0 ? 'text-danger fw-bold' : 'text-success'}`}
>
{short}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{data?.shortages && Object.keys(data.shortages).length > 0 ? (
<div className="alert alert-danger mt-3">
<strong>Shortages Detected</strong>
<ul className="mb-0">
{Object.entries(data.shortages).map(([item, short]) => (
<li key={item}>
{item}: short by {Number(short)} pcs
</li>
))}
</ul>
</div>
) : data && Object.keys(data.total_needed ?? {}).length > 0 ? (
<div className="alert alert-success mt-3">All items are available in stock.</div>
) : null}
</div>
</div>
<style>{`
.classprep-muted-soft { opacity: 0.55; }
.classprep-card { border-radius: 12px; }
.classprep-card .card-header {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.classprep-card .card-footer {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
`}</style>
</div>
)
}