Files
alrahma_web_client/src/pages/certificates/CertificatesPage.tsx
T
2026-06-05 01:51:12 -04:00

420 lines
14 KiB
TypeScript

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<string, string> = {
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 <span className="badge bg-warning text-dark">Pending</span>
}
if (student.decision_labels.length === 0) {
return <span className="text-muted small"></span>
}
return (
<>
{student.decision_labels.map((label) => (
<span key={label} className={`badge bg-${colorMap[label] ?? 'secondary'} me-1`}>
{label}
</span>
))}
</>
)
}
function StatusDot({ hasPass, fullyDone }: { hasPass: boolean; fullyDone: boolean }) {
const className = !hasPass ? 'no-eligible' : fullyDone ? 'done' : 'pending'
return <span className={`cert-status-dot ${className}`} aria-hidden />
}
function CertificateSectionCard({
section,
schoolYear,
defaultCertDate,
onGenerated,
}: {
section: CertificateSectionRow
schoolYear: string
defaultCertDate: string
onGenerated: () => Promise<void> | void
}) {
const eligibleStudents = useMemo(
() => section.students.filter((student) => student.eligible),
[section.students],
)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [certDate, setCertDate] = useState(isoToday())
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(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 (
<form className="mb-5" onSubmit={handleGenerate}>
<h4 className="mt-4 mb-2 text-center">{section.section_name}</h4>
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div className="d-flex align-items-center gap-4 flex-wrap">
<span className="fw-semibold">
Students <span className="badge bg-secondary ms-1">{section.student_count}</span>
</span>
<span className="text-muted small">
<strong className="text-success">{section.pass_count}</strong> Pass
</span>
<span className="text-muted small">
<strong className="text-primary">{section.cert_count}</strong> Generated
</span>
<span className="text-muted small">
<strong className={section.remaining_count > 0 ? 'text-warning' : 'text-muted'}>
{section.remaining_count}
</strong>{' '}
Remaining
</span>
</div>
<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={(event) => setCertDate(event.target.value)}
title="Certificate date"
/>
</div>
</div>
<div className="table-responsive">
<table className="table table-hover table-striped align-middle mb-0">
<thead className="table-light">
<tr>
<th style={{ width: 40 }} className="text-center">
<input
className="form-check-input"
type="checkbox"
checked={allSelected}
ref={(node) => {
if (node) node.indeterminate = partiallySelected
}}
onChange={(event) => toggleAll(event.target.checked)}
/>
</th>
<th>First Name</th>
<th>Last Name</th>
<th className="text-center">Year Score</th>
<th>Decision</th>
<th>Certificate No.</th>
</tr>
</thead>
<tbody>
{section.students.map((student) => (
<tr key={student.student_id}>
<td className="text-center">
<input
className="form-check-input"
type="checkbox"
checked={selectedIds.includes(student.student_id)}
disabled={!student.eligible || busy}
onChange={() => toggleStudent(student.student_id)}
/>
</td>
<td>{student.firstname}</td>
<td>{student.lastname}</td>
<td className="text-center fw-semibold">{formatScore(student.year_score)}</td>
<td>
<DecisionBadge student={student} />
</td>
<td>
{student.certificate_number ? (
<button
type="button"
className="btn btn-link btn-sm p-0 font-monospace"
disabled={busy}
onClick={() => handleReprint(student.certificate_number as string)}
>
{student.certificate_number}
</button>
) : (
<span className="text-muted"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="d-flex justify-content-between align-items-center mt-2">
<span className="text-muted small">
{selectedIds.length} student{selectedIds.length !== 1 ? 's' : ''} selected
</span>
<button type="submit" className="btn btn-success" disabled={selectedIds.length === 0 || busy}>
{busy ? (
<>
<span className="spinner-border spinner-border-sm me-1" />
Generating...
</>
) : (
<>
<i className="bi bi-printer me-1" />
Generate & Print Certificates
</>
)}
</button>
</div>
</form>
)
}
export function CertificatesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const groupKey = searchParams.get('group') ?? ''
const [data, setData] = useState<CertificateDashboardPayload | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="container-fluid">
<div className="wrapper">
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-4 mb-3">
<div>
<h2 className="mb-1">
<i className="bi bi-award me-2" />
Generate Certificates
</h2>
<div className="text-muted small">
Students are eligible only when the saved final generated decision is Pass.
</div>
</div>
<Link className="btn btn-outline-secondary btn-sm" to="/app/administrator/certificates/log">
<i className="bi bi-journal-check me-1" />
Audit Log
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex justify-content-end mb-3">
<form className="d-flex gap-2 align-items-center" onSubmit={applySchoolYear}>
<label className="form-label mb-0 me-1 text-muted small">School Year</label>
<input
type="text"
className="form-control form-control-sm"
style={{ width: 130 }}
value={yearInput}
onChange={(event) => setYearInput(event.target.value)}
placeholder="e.g. 2024-2025"
/>
<button type="submit" className="btn btn-sm btn-outline-primary">
<i className="bi bi-arrow-repeat me-1" />
Reload
</button>
</form>
</div>
{loading ? <div className="text-muted">Loading certificates</div> : null}
{!loading && groups.length === 0 ? (
<div className="alert alert-info">No classes found for {data?.school_year || schoolYear || 'this year'}.</div>
) : null}
{!loading && groups.length > 0 ? (
<>
<ul className="nav nav-tabs justify-content-center" style={{ flexWrap: 'wrap', rowGap: '.25rem' }}>
{groups.map((group) => (
<li className="nav-item" key={group.key}>
<button
type="button"
className={`nav-link ${group.key === activeGroupKey ? 'active' : ''}`}
onClick={() => setActiveGroup(group.key)}
>
<StatusDot hasPass={group.has_pass} fullyDone={group.fully_done} />
{group.label}
<span className="badge bg-secondary ms-1">{group.total}</span>
</button>
</li>
))}
</ul>
<div className="mt-3">
{activeGroup?.sections.map((section) => (
<CertificateSectionCard
key={section.section_id}
section={section}
schoolYear={data?.school_year ?? schoolYear}
defaultCertDate={data?.cert_date ?? ''}
onGenerated={() => loadDashboard(data?.school_year ?? schoolYear)}
/>
))}
</div>
</>
) : null}
</div>
<style>{`
.cert-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
flex-shrink: 0;
}
.cert-status-dot.done { background-color: #198754; }
.cert-status-dot.pending { background-color: #dc3545; }
.cert-status-dot.no-eligible { background-color: #adb5bd; }
`}</style>
</div>
)
}