fix teacher, parent and admin pages
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user