@@ -182,7 +237,7 @@ export function NavBuilderPage() {
) : (
- payload.items.map((item) => (
+ sortedItems.map((item) => (
|
|
{item.url ? {item.url} : -} |
- {parentLabel(item, payload.items)} |
+ {parentLabel(item, sortedItems)} |
{(item.roles?.names ?? []).length > 0 ? (
item.roles?.names?.map((roleName) => (
@@ -211,21 +266,12 @@ export function NavBuilderPage() {
)}
|
- {
- const next = Number(event.target.value) || 0
- setPayload((current) => ({
- ...current,
- items: current.items.map((row) => (row.id === item.id ? { ...row, sort_order: next } : row)),
- }))
- }}
- />
+
+ {alphabeticalOrders[String(item.id)] ?? '-'}
+
|
- {Boolean(item.is_enabled ?? item.enabled) ? (
+ {(item.is_enabled ?? item.enabled) ? (
Yes
) : (
No
@@ -312,15 +358,8 @@ export function NavBuilderPage() {
-
- setForm((current) => ({ ...current, sort_order: event.target.value }))}
- />
+
+ Alphabetical
diff --git a/src/pages/certificates/CertificatesAuditLogPage.tsx b/src/pages/certificates/CertificatesAuditLogPage.tsx
new file mode 100644
index 0000000..77bf4e2
--- /dev/null
+++ b/src/pages/certificates/CertificatesAuditLogPage.tsx
@@ -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 (null)
+ const [error, setError] = useState(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 (
+
+
+
+
+
+ Certificate Audit Log
+
+
+ Every issued certificate is recorded here for tracking and auditing.
+
+
+
+
+ Generate Certificates
+
+
+
+ {error ? {error} : null}
+ {loading ? Loading audit log… : null}
+
+ {(data?.year_summary?.length ?? 0) > 0 ? (
+
+ {data?.year_summary.map((row) => (
+
+
+ {row.total}
+ {row.school_year}
+
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Issued Certificates {data?.records.length ?? 0}
+
+
+
+
+
+
+
+ | Certificate # |
+ Student |
+ Grade |
+ Cert Date |
+ School Year |
+ Issued By |
+ Issued At |
+
+
+
+ {(data?.records.length ?? 0) === 0 ? (
+
+ |
+ No certificates issued yet.
+ |
+
+ ) : (
+ data?.records.map((record) => (
+
+ {record.certificate_number} |
+ {record.student_name} |
+ {record.grade || '—'} |
+ {formatDate(record.cert_date)} |
+ {record.school_year || '—'} |
+ {`${record.admin_firstname ?? ''} ${record.admin_lastname ?? ''}`.trim() || '—'} |
+ {formatDateTime(record.issued_at)} |
+
+ ))
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/certificates/CertificatesPage.tsx b/src/pages/certificates/CertificatesPage.tsx
index 398d214..edb2438 100644
--- a/src/pages/certificates/CertificatesPage.tsx
+++ b/src/pages/certificates/CertificatesPage.tsx
@@ -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([])
- const [students, setStudents] = useState([])
- const [currentYear, setCurrentYear] = useState('')
- const [loadingStudents, setLoadingStudents] = useState(false)
- const [error, setError] = useState(null)
+function isoToday() {
+ return new Date().toISOString().slice(0, 10)
+}
- const [selectedIds, setSelectedIds] = useState>(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(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 = {
+ 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)
- // 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 (
-
-
-
-
-
- Generate Certificates
-
-
- Select a class, choose students, then generate and print their certificates.
-
-
-
+ |