import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' import { fetchCertificateReprint, fetchCertificatesDashboard, postCertificateGenerate, type CertificateDashboardPayload, type CertificateSectionRow, type CertificateStudentRow, } from '../../api/certificates' function formatScore(value: number | null) { return value == null ? '—' : value.toFixed(2) } function isoToday() { return new Date().toISOString().slice(0, 10) } function toDisplayDate(isoDate: string) { const date = new Date(`${isoDate}T00:00:00`) if (Number.isNaN(date.getTime())) return isoDate return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${date.getFullYear()}` } function downloadBlob(blob: Blob) { const url = URL.createObjectURL(blob) window.open(url, '_blank', 'noopener') window.setTimeout(() => URL.revokeObjectURL(url), 60_000) } function DecisionBadge({ student }: { student: CertificateStudentRow }) { const colorMap: Record = { Pass: 'success', 'Repeat Class': 'danger', 'Make-up exam in fall': 'info', 'Deferred decision': 'info', Expel: 'danger', Withdrawn: 'secondary', } if (student.decision_rows.length === 0 || student.decision_state === 'pending') { return Pending } if (student.decision_labels.length === 0) { return } return ( <> {student.decision_labels.map((label) => ( {label} ))} ) } function StatusDot({ hasPass, fullyDone }: { hasPass: boolean; fullyDone: boolean }) { const className = !hasPass ? 'no-eligible' : fullyDone ? 'done' : 'pending' return } function CertificateSectionCard({ section, schoolYear, defaultCertDate, onGenerated, }: { section: CertificateSectionRow schoolYear: string defaultCertDate: string onGenerated: () => Promise | void }) { const eligibleStudents = useMemo( () => section.students.filter((student) => student.eligible), [section.students], ) const [selectedIds, setSelectedIds] = useState([]) const [certDate, setCertDate] = useState(isoToday()) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) useEffect(() => { setSelectedIds([]) setCertDate(isoToday()) setError(null) }, [section.section_id, defaultCertDate]) const allSelected = eligibleStudents.length > 0 && selectedIds.length === eligibleStudents.length const partiallySelected = selectedIds.length > 0 && !allSelected async function handleGenerate(event: FormEvent) { event.preventDefault() if (selectedIds.length === 0) { setError('Please select at least one student.') return } setBusy(true) setError(null) try { const blob = await postCertificateGenerate({ student_ids: selectedIds, cert_date: toDisplayDate(certDate), class_section_id: section.section_id, school_year: schoolYear, }) downloadBlob(blob) await onGenerated() setSelectedIds([]) } catch (e) { setError(e instanceof ApiHttpError ? e.message : 'Failed to generate certificates.') } finally { setBusy(false) } } async function handleReprint(certificateNumber: string) { setBusy(true) setError(null) try { const blob = await fetchCertificateReprint(certificateNumber) downloadBlob(blob) } catch (e) { setError(e instanceof ApiHttpError ? e.message : 'Failed to reprint certificate.') } finally { setBusy(false) } } function toggleStudent(studentId: number) { setSelectedIds((previous) => previous.includes(studentId) ? previous.filter((id) => id !== studentId) : [...previous, studentId], ) } function toggleAll(checked: boolean) { setSelectedIds(checked ? eligibleStudents.map((student) => student.student_id) : []) } return (

{section.section_name}

{error ?
{error}
: null}
Students {section.student_count} {section.pass_count} Pass {section.cert_count} Generated 0 ? 'text-warning' : 'text-muted'}> {section.remaining_count} {' '} Remaining
setCertDate(event.target.value)} title="Certificate date" />
{section.students.map((student) => ( ))}
{ if (node) node.indeterminate = partiallySelected }} onChange={(event) => toggleAll(event.target.checked)} /> First Name Last Name Year Score Decision Certificate No.
toggleStudent(student.student_id)} /> {student.firstname} {student.lastname} {formatScore(student.year_score)} {student.certificate_number ? ( ) : ( )}
{selectedIds.length} student{selectedIds.length !== 1 ? 's' : ''} selected
) } export function CertificatesPage() { const [searchParams, setSearchParams] = useSearchParams() const schoolYear = searchParams.get('school_year') ?? '' const groupKey = searchParams.get('group') ?? '' const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [yearInput, setYearInput] = useState('') async function loadDashboard(currentSchoolYear: string) { setLoading(true) setError(null) try { const payload = await fetchCertificatesDashboard({ school_year: currentSchoolYear || undefined, }) setData(payload) setYearInput(payload.school_year || currentSchoolYear) } catch (e) { setError(e instanceof ApiHttpError ? e.message : 'Failed to load certificate dashboard.') } finally { setLoading(false) } } useEffect(() => { void loadDashboard(schoolYear) }, [schoolYear]) const groups = data?.grade_groups ?? [] const activeGroupKey = (groupKey && groups.some((group) => group.key === groupKey) ? groupKey : '') || data?.default_group_key || groups[0]?.key || '' const activeGroup = groups.find((group) => group.key === activeGroupKey) ?? null function applySchoolYear(event: FormEvent) { event.preventDefault() const next = new URLSearchParams(searchParams) if (yearInput.trim()) next.set('school_year', yearInput.trim()) else next.delete('school_year') next.delete('group') setSearchParams(next, { replace: true }) } function setActiveGroup(nextGroupKey: string) { const next = new URLSearchParams(searchParams) next.set('group', nextGroupKey) if (schoolYear) next.set('school_year', schoolYear) setSearchParams(next, { replace: true }) } return (

Generate Certificates

Students are eligible only when the saved final generated decision is Pass.
Audit Log
{error ?
{error}
: null}
setYearInput(event.target.value)} placeholder="e.g. 2024-2025" />
{loading ?
Loading certificates…
: null} {!loading && groups.length === 0 ? (
No classes found for {data?.school_year || schoolYear || 'this year'}.
) : null} {!loading && groups.length > 0 ? ( <>
    {groups.map((group) => (
  • ))}
{activeGroup?.sections.map((section) => ( loadDashboard(data?.school_year ?? schoolYear)} /> ))}
) : null}
) }