Files
alrahma_web_client/src/pages/classPrep/ClassPrepListPage.tsx
T
2026-04-25 00:00:10 -04:00

350 lines
14 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 { 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>
)
}