fix financial and certificates
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchCertificatesAuditLog, type CertificateAuditPayload } from '../../api/certificates'
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function CertificatesAuditLogPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
|
||||
const [data, setData] = useState<CertificateAuditPayload | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
fetchCertificatesAuditLog({ school_year: schoolYear || undefined })
|
||||
.then((payload) => {
|
||||
if (!cancelled) setData(payload)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to load certificate audit log.')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear])
|
||||
|
||||
function onYearChange(nextYear: string) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (nextYear) next.set('school_year', nextYear)
|
||||
else next.delete('school_year')
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
|
||||
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-journal-check me-2" />
|
||||
Certificate Audit Log
|
||||
</h2>
|
||||
<div className="text-muted small">
|
||||
Every issued certificate is recorded here for tracking and auditing.
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/app/administrator/certificates" className="btn btn-outline-secondary btn-sm">
|
||||
<i className="bi bi-award me-1" />
|
||||
Generate Certificates
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <div className="text-muted">Loading audit log…</div> : null}
|
||||
|
||||
{(data?.year_summary?.length ?? 0) > 0 ? (
|
||||
<div className="row g-3 mb-4">
|
||||
{data?.year_summary.map((row) => (
|
||||
<div className="col-auto" key={row.school_year}>
|
||||
<div className="card shadow-sm text-center px-4 py-2" style={{ minWidth: 150 }}>
|
||||
<div className="fw-bold fs-4">{row.total}</div>
|
||||
<div className="text-muted small">{row.school_year}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-body py-2">
|
||||
<div className="row g-2 align-items-center">
|
||||
<div className="col-auto">
|
||||
<label className="col-form-label fw-semibold">School Year</label>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={schoolYear}
|
||||
onChange={(event) => onYearChange(event.target.value)}
|
||||
>
|
||||
<option value="">— All years —</option>
|
||||
{(data?.year_summary ?? []).map((row) => (
|
||||
<option key={row.school_year} value={row.school_year}>
|
||||
{row.school_year} ({row.total})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<span className="fw-semibold">
|
||||
Issued Certificates <span className="badge bg-secondary ms-1">{data?.records.length ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-body p-0">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover table-striped align-middle mb-0 no-mgmt-sticky">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Certificate #</th>
|
||||
<th>Student</th>
|
||||
<th>Grade</th>
|
||||
<th>Cert Date</th>
|
||||
<th>School Year</th>
|
||||
<th>Issued By</th>
|
||||
<th>Issued At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.records.length ?? 0) === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-muted py-4">
|
||||
No certificates issued yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data?.records.map((record) => (
|
||||
<tr key={`${record.certificate_number}-${record.issued_at ?? ''}`}>
|
||||
<td><code>{record.certificate_number}</code></td>
|
||||
<td>{record.student_name}</td>
|
||||
<td>{record.grade || '—'}</td>
|
||||
<td>{formatDate(record.cert_date)}</td>
|
||||
<td>{record.school_year || '—'}</td>
|
||||
<td>{`${record.admin_firstname ?? ''} ${record.admin_lastname ?? ''}`.trim() || '—'}</td>
|
||||
<td>{formatDateTime(record.issued_at)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,320 +1,419 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
fetchCertFormOptions,
|
||||
fetchCertificateReprint,
|
||||
fetchCertificatesDashboard,
|
||||
postCertificateGenerate,
|
||||
type CertClassSection,
|
||||
type CertStudent,
|
||||
type CertificateDashboardPayload,
|
||||
type CertificateSectionRow,
|
||||
type CertificateStudentRow,
|
||||
} from '../../api/certificates'
|
||||
|
||||
export function CertificatesPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const classId = searchParams.get('class_section_id') ?? ''
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
function formatScore(value: number | null) {
|
||||
return value == null ? '—' : value.toFixed(2)
|
||||
}
|
||||
|
||||
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)
|
||||
function isoToday() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
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)
|
||||
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()}`
|
||||
}
|
||||
|
||||
const selectAllRef = useRef<HTMLInputElement>(null)
|
||||
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)
|
||||
|
||||
// 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])
|
||||
setSelectedIds([])
|
||||
setCertDate(isoToday())
|
||||
setError(null)
|
||||
}, [section.section_id, defaultCertDate])
|
||||
|
||||
// Load students when class changes
|
||||
useEffect(() => {
|
||||
if (!classId) {
|
||||
setStudents([])
|
||||
setSelectedIds(new Set())
|
||||
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
|
||||
}
|
||||
setLoadingStudents(true)
|
||||
|
||||
setBusy(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())
|
||||
|
||||
try {
|
||||
const blob = await postCertificateGenerate({
|
||||
student_ids: selectedIds,
|
||||
cert_date: toDisplayDate(certDate),
|
||||
class_section_id: section.section_id,
|
||||
school_year: schoolYear,
|
||||
})
|
||||
.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
|
||||
downloadBlob(blob)
|
||||
await onGenerated()
|
||||
setSelectedIds([])
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to generate certificates.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [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 })
|
||||
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(id: number) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
function toggleStudent(studentId: number) {
|
||||
setSelectedIds((previous) =>
|
||||
previous.includes(studentId)
|
||||
? previous.filter((id) => id !== studentId)
|
||||
: [...previous, studentId],
|
||||
)
|
||||
}
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
if (checked) setSelectedIds(new Set(students.map((s) => s.id)))
|
||||
else setSelectedIds(new Set())
|
||||
setSelectedIds(checked ? eligibleStudents.map((student) => student.student_id) : [])
|
||||
}
|
||||
|
||||
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>
|
||||
<form className="mb-5" onSubmit={handleGenerate}>
|
||||
<h4 className="mt-4 mb-2 text-center">{section.section_name}</h4>
|
||||
|
||||
{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"
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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"
|
||||
id="selectAll"
|
||||
ref={selectAllRef}
|
||||
onChange={(e) => toggleAll(e.target.checked)}
|
||||
checked={selectedIds.includes(student.student_id)}
|
||||
disabled={!student.eligible || busy}
|
||||
onChange={() => toggleStudent(student.student_id)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="selectAll">
|
||||
Select all
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="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="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>
|
||||
)
|
||||
}
|
||||
|
||||
<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 & Print Certificates
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
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>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!loadingStudents && classId && !hasStudents && (
|
||||
<div className="alert alert-warning">
|
||||
No active students found for the selected class and school year.
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!loadingStudents && !classId && (
|
||||
<div className="alert alert-info">
|
||||
Select a class above to load its student roster.
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user