add certificate

This commit is contained in:
root
2026-05-18 00:07:40 -04:00
parent dc7acf2536
commit 0d3cdd3857
13 changed files with 869 additions and 44 deletions
+320
View File
@@ -0,0 +1,320 @@
import { type FormEvent, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchCertFormOptions,
postCertificateGenerate,
type CertClassSection,
type CertStudent,
} from '../../api/certificates'
export function CertificatesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const classId = searchParams.get('class_section_id') ?? ''
const schoolYear = searchParams.get('school_year') ?? ''
const [classSections, setClassSections] = useState<CertClassSection[]>([])
const [students, setStudents] = useState<CertStudent[]>([])
const [currentYear, setCurrentYear] = useState('')
const [loadingStudents, setLoadingStudents] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [certDate, setCertDate] = useState(() => {
const d = new Date()
return d.toISOString().slice(0, 10)
})
const [generating, setGenerating] = useState(false)
const selectAllRef = useRef<HTMLInputElement>(null)
// Load class sections on mount / school_year change
useEffect(() => {
fetchCertFormOptions({ school_year: schoolYear || undefined })
.then((raw: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = (raw as any)?.data ?? raw
setClassSections(Array.isArray(payload?.class_sections) ? payload.class_sections : [])
setCurrentYear(typeof payload?.school_year === 'string' ? payload.school_year : '')
})
.catch((err: unknown) => {
console.error('[Certificates] form-options error:', err)
if (err instanceof ApiHttpError) {
setError(`API error ${err.status}: ${err.message}`)
} else if (err instanceof Error) {
setError(`Error: ${err.message}`)
} else {
setError('Failed to load classes.')
}
})
}, [schoolYear])
// Load students when class changes
useEffect(() => {
if (!classId) {
setStudents([])
setSelectedIds(new Set())
return
}
setLoadingStudents(true)
setError(null)
fetchCertFormOptions({ class_section_id: classId, school_year: schoolYear || undefined })
.then((raw: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = (raw as any)?.data ?? raw
setStudents(Array.isArray(payload?.students) ? payload.students : [])
setSelectedIds(new Set())
})
.catch(() => setStudents([]))
.finally(() => setLoadingStudents(false))
}, [classId, schoolYear])
// Keep select-all checkbox tri-state in sync
useEffect(() => {
const el = selectAllRef.current
if (!el) return
if (students.length === 0) {
el.checked = false
el.indeterminate = false
} else if (selectedIds.size === students.length) {
el.checked = true
el.indeterminate = false
} else if (selectedIds.size === 0) {
el.checked = false
el.indeterminate = false
} else {
el.checked = false
el.indeterminate = true
}
}, [selectedIds, students])
function handleClassChange(value: string) {
const next = new URLSearchParams(searchParams)
if (value) next.set('class_section_id', value)
else next.delete('class_section_id')
setSearchParams(next, { replace: true })
}
function handleSchoolYearChange(value: string) {
const next = new URLSearchParams(searchParams)
if (value) next.set('school_year', value)
else next.delete('school_year')
next.delete('class_section_id')
setSearchParams(next, { replace: true })
}
function toggleStudent(id: number) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAll(checked: boolean) {
if (checked) setSelectedIds(new Set(students.map((s) => s.id)))
else setSelectedIds(new Set())
}
function formatCertDate(isoDate: string): string {
const d = new Date(isoDate + 'T00:00:00')
if (isNaN(d.getTime())) return isoDate
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}/${dd}/${d.getFullYear()}`
}
async function handleGenerate(e: FormEvent) {
e.preventDefault()
if (selectedIds.size === 0) return
setGenerating(true)
setError(null)
try {
const blob = await postCertificateGenerate({
student_ids: Array.from(selectedIds),
cert_date: formatCertDate(certDate),
class_section_id: classId ? Number(classId) : null,
})
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
} catch (err) {
setError(err instanceof ApiHttpError ? err.message : 'Failed to generate certificates.')
} finally {
setGenerating(false)
}
}
const hasStudents = students.length > 0
return (
<div className="container-fluid py-4">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 className="mb-0">
<i className="bi bi-award me-2" />
Generate Certificates
</h2>
<div className="text-muted small">
Select a class, choose students, then generate and print their certificates.
</div>
</div>
</div>
{error && (
<div className="alert alert-danger alert-dismissible fade show" role="alert">
{error}
<button
type="button"
className="btn-close"
onClick={() => setError(null)}
aria-label="Close"
/>
</div>
)}
{/* Filter */}
<div className="card shadow-sm mb-4">
<div className="card-header fw-semibold">Filter Students</div>
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-12 col-md-5">
<label className="form-label fw-semibold mb-1">Class / Section</label>
<select
className="form-select"
value={classId}
onChange={(e) => handleClassChange(e.target.value)}
>
<option value=""> Select a class </option>
{classSections.map((cs) => (
<option key={cs.class_section_id} value={String(cs.class_section_id)}>
{cs.class_section_name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-3">
<label className="form-label fw-semibold mb-1">School Year</label>
<input
type="text"
className="form-control"
placeholder="e.g. 2024-2025"
value={schoolYear || currentYear}
onChange={(e) => handleSchoolYearChange(e.target.value)}
/>
</div>
</div>
</div>
</div>
{loadingStudents && (
<div className="text-muted">Loading students</div>
)}
{!loadingStudents && hasStudents && (
<form onSubmit={handleGenerate}>
<div className="card shadow-sm mb-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span className="fw-semibold">
Students
<span className="badge bg-secondary ms-1">{students.length}</span>
</span>
<div className="d-flex align-items-center gap-3">
<div className="input-group input-group-sm" style={{ width: 200 }}>
<span className="input-group-text">
<i className="bi bi-calendar3" />
</span>
<input
type="date"
className="form-control"
value={certDate}
onChange={(e) => setCertDate(e.target.value)}
title="Certificate date"
/>
</div>
<div className="form-check mb-0">
<input
className="form-check-input"
type="checkbox"
id="selectAll"
ref={selectAllRef}
onChange={(e) => toggleAll(e.target.checked)}
/>
<label className="form-check-label" htmlFor="selectAll">
Select all
</label>
</div>
</div>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-hover table-striped align-middle mb-0">
<thead className="table-light">
<tr>
<th style={{ width: 40 }} />
<th>Last Name</th>
<th>First Name</th>
<th>Grade / Class</th>
</tr>
</thead>
<tbody>
{students.map((s) => (
<tr key={s.id}>
<td className="text-center">
<input
className="form-check-input"
type="checkbox"
checked={selectedIds.has(s.id)}
onChange={() => toggleStudent(s.id)}
/>
</td>
<td>{s.lastname}</td>
<td>{s.firstname}</td>
<td>{s.grade}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card-footer d-flex justify-content-between align-items-center">
<span className="text-muted small">
{selectedIds.size} student{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<button
type="submit"
className="btn btn-success"
disabled={selectedIds.size === 0 || generating}
>
{generating ? (
<>
<span className="spinner-border spinner-border-sm me-1" role="status" />
Generating
</>
) : (
<>
<i className="bi bi-printer me-1" />
Generate &amp; Print Certificates
</>
)}
</button>
</div>
</div>
</form>
)}
{!loadingStudents && classId && !hasStudents && (
<div className="alert alert-warning">
No active students found for the selected class and school year.
</div>
)}
{!loadingStudents && !classId && (
<div className="alert alert-info">
Select a class above to load its student roster.
</div>
)}
</div>
)
}