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