fix financial and certificates
This commit is contained in:
+26
@@ -508,6 +508,11 @@ const TeacherPrintRequestsPage = lazy(() =>
|
||||
const CertificatesPage = lazy(() =>
|
||||
import('./pages/certificates/CertificatesPage').then((m) => ({ default: m.CertificatesPage })),
|
||||
)
|
||||
const CertificatesAuditLogPage = lazy(() =>
|
||||
import('./pages/certificates/CertificatesAuditLogPage').then((m) => ({
|
||||
default: m.CertificatesAuditLogPage,
|
||||
})),
|
||||
)
|
||||
const StickerFormPage = lazy(() =>
|
||||
import('./pages/printables/StickerFormPage').then((m) => ({ default: m.StickerFormPage })),
|
||||
)
|
||||
@@ -622,6 +627,21 @@ const ScorePredictionPage = lazy(() =>
|
||||
default: m.ScorePredictionPage,
|
||||
})),
|
||||
)
|
||||
const TrophyProjectionPage = lazy(() =>
|
||||
import('./pages/trophy/TrophyPages').then((m) => ({
|
||||
default: m.TrophyProjectionPage,
|
||||
})),
|
||||
)
|
||||
const TrophyWinnersPage = lazy(() =>
|
||||
import('./pages/trophy/TrophyPages').then((m) => ({
|
||||
default: m.TrophyWinnersPage,
|
||||
})),
|
||||
)
|
||||
const TrophyFinalPage = lazy(() =>
|
||||
import('./pages/trophy/TrophyPages').then((m) => ({
|
||||
default: m.TrophyFinalPage,
|
||||
})),
|
||||
)
|
||||
const SlipPreviewListPage = lazy(() =>
|
||||
import('./pages/slips/SlipPreviewListPage').then((m) => ({ default: m.SlipPreviewListPage })),
|
||||
)
|
||||
@@ -1268,10 +1288,16 @@ export default function App() {
|
||||
element={<ReportCardManagementPage />}
|
||||
/>
|
||||
<Route path="administrator/certificates" element={<CertificatesPage />} />
|
||||
<Route path="administrator/certificates/log" element={<CertificatesAuditLogPage />} />
|
||||
<Route
|
||||
path="administrator/score-analysis/score-prediction"
|
||||
element={<ScorePredictionPage />}
|
||||
/>
|
||||
<Route path="administrator/trophy" element={<TrophyProjectionPage />} />
|
||||
<Route path="administrator/trophy/winners" element={<TrophyWinnersPage />} />
|
||||
<Route path="administrator/trophy/final" element={<TrophyFinalPage />} />
|
||||
<Route path="administrator/trophy_winners" element={<TrophyWinnersPage />} />
|
||||
<Route path="administrator/trophy_final" element={<TrophyFinalPage />} />
|
||||
<Route path="administrator/slips/preview" element={<SlipPreviewListPage />} />
|
||||
<Route path="administrator/slips/print" element={<LateSlipPrintPage />} />
|
||||
<Route path="slips/print" element={<LateSlipPrintPage />} />
|
||||
|
||||
+122
-32
@@ -2,64 +2,154 @@ import { apiUrl } from '../lib/apiOrigin'
|
||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||
import type { ApiEnvelope } from './types'
|
||||
|
||||
export type CertClassSection = {
|
||||
class_section_id: number
|
||||
class_section_name: string
|
||||
export type CertificateDecisionRow = {
|
||||
decision: string
|
||||
source: string
|
||||
notes: string
|
||||
year_score: number | null
|
||||
}
|
||||
|
||||
export type CertStudent = {
|
||||
id: number
|
||||
export type CertificateStudentRow = {
|
||||
student_id: number
|
||||
firstname: string
|
||||
lastname: string
|
||||
grade: string
|
||||
class_section_id: number
|
||||
class_section_name: string
|
||||
year_score: number | null
|
||||
eligible: boolean
|
||||
decision_state: 'pending' | 'pass' | 'decision'
|
||||
decision_labels: string[]
|
||||
decision_rows: CertificateDecisionRow[]
|
||||
certificate_number: string | null
|
||||
}
|
||||
|
||||
export type CertFormOptions = {
|
||||
class_sections: CertClassSection[]
|
||||
students: CertStudent[]
|
||||
export type CertificateSectionRow = {
|
||||
section_id: number
|
||||
section_name: string
|
||||
student_count: number
|
||||
pass_count: number
|
||||
cert_count: number
|
||||
remaining_count: number
|
||||
students: CertificateStudentRow[]
|
||||
}
|
||||
|
||||
export type CertificateGradeGroup = {
|
||||
key: string
|
||||
label: string
|
||||
slug: string
|
||||
total: number
|
||||
pass: number
|
||||
cert: number
|
||||
fully_done: boolean
|
||||
has_pass: boolean
|
||||
sections: CertificateSectionRow[]
|
||||
}
|
||||
|
||||
export type CertificateDashboardPayload = {
|
||||
school_year: string
|
||||
selected_class_id: string | null
|
||||
cert_date: string
|
||||
grade_groups: CertificateGradeGroup[]
|
||||
stats_per_class: Record<string, { name: string; total: number; pass: number; cert: number }>
|
||||
default_group_key: string | null
|
||||
}
|
||||
|
||||
export async function fetchCertFormOptions(params?: {
|
||||
school_year?: string
|
||||
class_section_id?: string | number
|
||||
}): Promise<ApiEnvelope<CertFormOptions>> {
|
||||
export type CertificateAuditRecord = {
|
||||
certificate_number: string
|
||||
student_name: string
|
||||
grade?: string | null
|
||||
cert_date?: string | null
|
||||
school_year?: string | null
|
||||
admin_firstname?: string | null
|
||||
admin_lastname?: string | null
|
||||
issued_at?: string | null
|
||||
}
|
||||
|
||||
export type CertificateAuditPayload = {
|
||||
school_year: string
|
||||
records: CertificateAuditRecord[]
|
||||
year_summary: Array<{ school_year: string; total: number }>
|
||||
}
|
||||
|
||||
function authHeaders(accept: string) {
|
||||
const headers = new Headers({ Accept: accept })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
return headers
|
||||
}
|
||||
|
||||
function withQuery(path: string, params?: Record<string, string | number | null | undefined>) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.class_section_id != null && String(params.class_section_id) !== '')
|
||||
qs.set('class_section_id', String(params.class_section_id))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch<ApiEnvelope<CertFormOptions>>(`/api/v1/administrator/certificates/form-options${suffix}`)
|
||||
Object.entries(params ?? {}).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
qs.set(key, String(value))
|
||||
}
|
||||
})
|
||||
return qs.size > 0 ? `${path}?${qs.toString()}` : path
|
||||
}
|
||||
|
||||
export async function fetchCertificatesDashboard(params?: {
|
||||
school_year?: string
|
||||
}): Promise<CertificateDashboardPayload> {
|
||||
const res = await apiFetch<ApiEnvelope<CertificateDashboardPayload>>(
|
||||
withQuery('/api/v1/administrator/certificates/dashboard', params),
|
||||
)
|
||||
return (res.data ?? {}) as CertificateDashboardPayload
|
||||
}
|
||||
|
||||
export async function fetchCertificatesAuditLog(params?: {
|
||||
school_year?: string
|
||||
}): Promise<CertificateAuditPayload> {
|
||||
const res = await apiFetch<ApiEnvelope<CertificateAuditPayload>>(
|
||||
withQuery('/api/v1/administrator/certificates/audit-log', params),
|
||||
)
|
||||
return (res.data ?? {}) as CertificateAuditPayload
|
||||
}
|
||||
|
||||
export async function postCertificateGenerate(payload: {
|
||||
student_ids: number[]
|
||||
cert_date: string
|
||||
class_section_id?: number | string | null
|
||||
class_section_id?: number | null
|
||||
school_year?: string | null
|
||||
}): Promise<Blob> {
|
||||
const headers = new Headers({
|
||||
Accept: 'application/pdf,*/*',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(apiUrl('/api/v1/administrator/certificates/generate'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: (() => {
|
||||
const headers = authHeaders('application/pdf,*/*')
|
||||
headers.set('Content-Type', 'application/json')
|
||||
return headers
|
||||
})(),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody: unknown = await res.json().catch(() => null)
|
||||
const msg =
|
||||
const message =
|
||||
typeof errBody === 'object' && errBody !== null && 'message' in errBody
|
||||
? String((errBody as { message?: unknown }).message ?? '')
|
||||
: ''
|
||||
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody)
|
||||
throw new ApiHttpError(message || `HTTP ${res.status}`, res.status, errBody)
|
||||
}
|
||||
|
||||
const raw = await res.blob()
|
||||
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export async function fetchCertificateReprint(certificateNumber: string): Promise<Blob> {
|
||||
const res = await fetch(
|
||||
apiUrl(`/api/v1/administrator/certificates/reprint/${encodeURIComponent(certificateNumber)}`),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: authHeaders('application/pdf,*/*'),
|
||||
},
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody: unknown = await res.json().catch(() => null)
|
||||
const message =
|
||||
typeof errBody === 'object' && errBody !== null && 'message' in errBody
|
||||
? String((errBody as { message?: unknown }).message ?? '')
|
||||
: ''
|
||||
throw new ApiHttpError(message || `HTTP ${res.status}`, res.status, errBody)
|
||||
}
|
||||
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { apiFetch, getStoredToken } from './http'
|
||||
|
||||
const BASE = '/api/v1/administrator/invoices'
|
||||
const BASE = '/api/v1/finance/invoices'
|
||||
|
||||
function q(searchParams: URLSearchParams): string {
|
||||
const s = searchParams.toString()
|
||||
@@ -35,7 +35,7 @@ export async function fetchInvoiceManagement(
|
||||
}
|
||||
|
||||
export async function postInvoiceGenerate(parentId: number): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/generate-for-parent`, {
|
||||
return apiFetch(`${BASE}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parent_id: parentId }),
|
||||
|
||||
@@ -175,7 +175,17 @@ export async function fetchUnpaidParents(params?: {
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch(`/api/v1/administrator/payments/unpaid-parents${suffix}`)
|
||||
return apiFetch<{
|
||||
rows?: UnpaidParentRow[]
|
||||
results?: UnpaidParentRow[]
|
||||
school_year?: string
|
||||
schoolYear?: string
|
||||
schoolYears?: string[]
|
||||
}>(`/api/v1/finance/unpaid-parents${suffix}`).then((body) => ({
|
||||
rows: body.rows ?? body.results ?? [],
|
||||
school_year: body.school_year ?? body.schoolYear,
|
||||
schoolYears: body.schoolYears ?? [],
|
||||
}))
|
||||
}
|
||||
|
||||
export async function sendPaymentReminders(payload: {
|
||||
@@ -287,6 +297,15 @@ export type FinancialDetailedJson = {
|
||||
eventFeesTotal?: number
|
||||
}
|
||||
|
||||
function unwrapFinancialReport(
|
||||
body: FinancialDetailedJson & { report?: FinancialDetailedJson },
|
||||
): FinancialDetailedJson {
|
||||
if (body && typeof body === 'object' && body.report && typeof body.report === 'object') {
|
||||
return body.report
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export async function fetchFinancialReportDetailed(params: {
|
||||
school_year?: string
|
||||
date_from?: string
|
||||
@@ -297,7 +316,10 @@ export async function fetchFinancialReportDetailed(params: {
|
||||
if (params.date_from) qs.set('date_from', params.date_from)
|
||||
if (params.date_to) qs.set('date_to', params.date_to)
|
||||
qs.set('format', 'json')
|
||||
return apiFetch(`/api/v1/administrator/reports/financial-detailed?${qs}`)
|
||||
const body = await apiFetch<FinancialDetailedJson & { report?: FinancialDetailedJson }>(
|
||||
`/api/v1/finance/financial-report?${qs}`,
|
||||
)
|
||||
return unwrapFinancialReport(body)
|
||||
}
|
||||
|
||||
export async function downloadFinancialReportCsv(params: {
|
||||
@@ -309,7 +331,7 @@ export async function downloadFinancialReportCsv(params: {
|
||||
if (params.school_year) qs.set('school_year', params.school_year)
|
||||
if (params.date_from) qs.set('date_from', params.date_from)
|
||||
if (params.date_to) qs.set('date_to', params.date_to)
|
||||
const path = `/api/v1/administrator/reports/financial-detailed.csv?${qs}`
|
||||
const path = `/api/v1/finance/financial-report/csv?${qs}`
|
||||
const headers = new Headers()
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
@@ -341,19 +363,48 @@ export type FinancialSummaryJson = {
|
||||
totalPaid?: number
|
||||
}
|
||||
|
||||
function unwrapFinancialSummary(
|
||||
body: FinancialSummaryJson & {
|
||||
summary?: FinancialSummaryJson
|
||||
schoolYears?: string[]
|
||||
},
|
||||
): FinancialSummaryJson {
|
||||
const summary =
|
||||
body && typeof body === 'object' && body.summary && typeof body.summary === 'object'
|
||||
? body.summary
|
||||
: body
|
||||
|
||||
if (
|
||||
body &&
|
||||
typeof body === 'object' &&
|
||||
Array.isArray(body.schoolYears) &&
|
||||
!Array.isArray((summary as { schoolYears?: unknown }).schoolYears)
|
||||
) {
|
||||
return {
|
||||
...summary,
|
||||
schoolYears: body.schoolYears,
|
||||
} as FinancialSummaryJson
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
export async function fetchFinancialReportSummary(params: {
|
||||
school_year?: string
|
||||
}): Promise<FinancialSummaryJson> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.school_year) qs.set('school_year', params.school_year)
|
||||
qs.set('format', 'json')
|
||||
return apiFetch(`/api/v1/administrator/reports/financial-summary?${qs}`)
|
||||
const body = await apiFetch<
|
||||
FinancialSummaryJson & { summary?: FinancialSummaryJson; schoolYears?: string[] }
|
||||
>(`/api/v1/finance/financial-summary?${qs}`)
|
||||
return unwrapFinancialSummary(body)
|
||||
}
|
||||
|
||||
export async function downloadFinancialSummaryPdf(params: { school_year?: string }): Promise<void> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.school_year) qs.set('school_year', params.school_year)
|
||||
const path = `/api/v1/administrator/reports/financial-summary.pdf?${qs}`
|
||||
const path = `/api/v1/finance/financial-report/pdf?${qs}`
|
||||
const headers = new Headers()
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Admin refunds list (legacy CI `refunds/list.php`).
|
||||
* Laravel: `GET /api/v1/administrator/refunds/list` with JWT.
|
||||
* Laravel: `GET /api/v1/finance/refunds` with JWT.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/administrator/refunds'
|
||||
const BASE = '/api/v1/finance/refunds'
|
||||
|
||||
export type RefundListRow = Record<string, unknown>
|
||||
|
||||
@@ -42,6 +42,6 @@ export async function fetchRefundsList(params?: {
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
if (params?.page != null) qs.set('page', String(params.page))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
const body = await apiFetch<unknown>(`${BASE}/list${suffix}`)
|
||||
const body = await apiFetch<unknown>(`${BASE}${suffix}`)
|
||||
return normalizeListPayload(body)
|
||||
}
|
||||
|
||||
+135
-11
@@ -89,7 +89,9 @@ import type {
|
||||
EnrollmentWithdrawalPageResponse,
|
||||
ExpensesListResponse,
|
||||
ExpenseCreateFormResponse,
|
||||
ExpenseDetail,
|
||||
ExpenseEditFormResponse,
|
||||
ExpenseUserOption,
|
||||
FamilyLegacyImportMetaResponse,
|
||||
FamilyLegacyImportResult,
|
||||
FamilySearchResponse,
|
||||
@@ -1039,12 +1041,16 @@ export async function openProtectedApiFile(path: string): Promise<void> {
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
|
||||
}
|
||||
|
||||
async function fetchMultipartJson<T>(path: string, formData: FormData): Promise<T> {
|
||||
async function fetchMultipartJson<T>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
init: { method?: 'POST' | 'PUT' | 'PATCH' } = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers({ Accept: 'application/json' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(apiUrl(path), {
|
||||
method: 'POST',
|
||||
method: init.method ?? 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
@@ -1193,6 +1199,68 @@ export async function fetchUsers(params?: {
|
||||
return apiFetch<UsersResponse>(`/api/v1/users${qs}`)
|
||||
}
|
||||
|
||||
let discountParentSchoolIdMapPromise: Promise<Map<string, string>> | null = null
|
||||
|
||||
function userRowHasParentRole(row: UserListRow): boolean {
|
||||
return (row.roles ?? []).some((role) => {
|
||||
const label =
|
||||
typeof role === 'string' ? role : `${role.name ?? ''} ${role.slug ?? ''}`
|
||||
return label.toLowerCase().includes('parent')
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchDiscountParentSchoolIdMap(): Promise<Map<string, string>> {
|
||||
if (!discountParentSchoolIdMapPromise) {
|
||||
discountParentSchoolIdMapPromise = (async () => {
|
||||
const data = await fetchUsers({ sort: 'lastname', order: 'asc' })
|
||||
const map = new Map<string, string>()
|
||||
|
||||
for (const row of data.users ?? []) {
|
||||
if (!userRowHasParentRole(row)) continue
|
||||
const user = row.user
|
||||
const userId = user?.id
|
||||
const schoolId = user?.school_id
|
||||
if (userId == null || schoolId == null) continue
|
||||
const normalizedSchoolId = String(schoolId).trim()
|
||||
if (normalizedSchoolId === '') continue
|
||||
map.set(String(userId), normalizedSchoolId)
|
||||
}
|
||||
|
||||
return map
|
||||
})().catch((error) => {
|
||||
discountParentSchoolIdMapPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return discountParentSchoolIdMapPromise
|
||||
}
|
||||
|
||||
async function enrichDiscountApplyContext(
|
||||
context: DiscountApplyContextResponse,
|
||||
): Promise<DiscountApplyContextResponse> {
|
||||
const parents = context.parents ?? []
|
||||
if (parents.length === 0) return context
|
||||
|
||||
try {
|
||||
const schoolIdMap = await fetchDiscountParentSchoolIdMap()
|
||||
return {
|
||||
...context,
|
||||
parents: parents.map((parent) => {
|
||||
if (typeof parent.school_id === 'string' && parent.school_id.trim() !== '') {
|
||||
return parent
|
||||
}
|
||||
|
||||
const schoolId = schoolIdMap.get(String(parent.id))
|
||||
if (!schoolId) return parent
|
||||
return { ...parent, school_id: schoolId }
|
||||
}),
|
||||
}
|
||||
} catch {
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapEnvelopeData(body: unknown): unknown {
|
||||
if (!body || typeof body !== 'object') return body
|
||||
const o = body as Record<string, unknown>
|
||||
@@ -2025,10 +2093,19 @@ export async function fetchDiscountApplyContext(params?: {
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
try {
|
||||
const body = await apiFetch<DiscountApplyContextResponse & { data?: DiscountApplyContextResponse }>(
|
||||
`/api/v1/discounts/options${suffix}`,
|
||||
)
|
||||
return enrichDiscountApplyContext(unwrapData(body))
|
||||
} catch (err: unknown) {
|
||||
if (!(err instanceof ApiHttpError) || err.status !== 404) throw err
|
||||
}
|
||||
|
||||
const body = await apiFetch<DiscountApplyContextResponse & { data?: DiscountApplyContextResponse }>(
|
||||
`/api/v1/discounts/apply-context${suffix}`,
|
||||
)
|
||||
return unwrapData(body)
|
||||
return enrichDiscountApplyContext(unwrapData(body))
|
||||
}
|
||||
|
||||
export async function createDiscountVoucher(payload: Record<string, unknown>): Promise<{ ok?: boolean; message?: string }> {
|
||||
@@ -2194,11 +2271,31 @@ export async function fetchExpensesList(params?: {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExpenseFormOptions(
|
||||
body: Partial<ExpenseCreateFormResponse> & {
|
||||
retailors?: string[]
|
||||
staff?: ExpenseUserOption[]
|
||||
},
|
||||
): ExpenseCreateFormResponse {
|
||||
return {
|
||||
retailors: body.retailors ?? [],
|
||||
users: body.users ?? body.staff ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchExpenseCreateForm(): Promise<ExpenseCreateFormResponse> {
|
||||
const body = await apiFetch<ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse }>(
|
||||
'/api/v1/administrator/expenses/create-options',
|
||||
'/api/v1/administrator/expenses/options',
|
||||
)
|
||||
return normalizeExpenseFormOptions(
|
||||
unwrapData(
|
||||
body as ExpenseCreateFormResponse & {
|
||||
data?: ExpenseCreateFormResponse
|
||||
retailors?: string[]
|
||||
staff?: ExpenseUserOption[]
|
||||
},
|
||||
),
|
||||
)
|
||||
return unwrapData(body as ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse })
|
||||
}
|
||||
|
||||
export async function createExpense(
|
||||
@@ -2208,26 +2305,53 @@ export async function createExpense(
|
||||
}
|
||||
|
||||
export async function fetchExpenseEdit(expenseId: number): Promise<ExpenseEditFormResponse> {
|
||||
const body = await apiFetch<ExpenseEditFormResponse & { data?: ExpenseEditFormResponse }>(
|
||||
`/api/v1/administrator/expenses/${expenseId}/edit`,
|
||||
const [expenseBody, optionsBody] = await Promise.all([
|
||||
apiFetch<{ expense?: ExpenseDetail; data?: { expense?: ExpenseDetail } }>(
|
||||
`/api/v1/administrator/expenses/${expenseId}`,
|
||||
),
|
||||
apiFetch<ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse }>(
|
||||
'/api/v1/administrator/expenses/options',
|
||||
),
|
||||
])
|
||||
const expenseData = unwrapData(
|
||||
expenseBody as { expense?: ExpenseDetail; data?: { expense?: ExpenseDetail } },
|
||||
)
|
||||
return unwrapData(body as ExpenseEditFormResponse & { data?: ExpenseEditFormResponse })
|
||||
const optionsData = normalizeExpenseFormOptions(
|
||||
unwrapData(
|
||||
optionsBody as ExpenseCreateFormResponse & {
|
||||
data?: ExpenseCreateFormResponse
|
||||
retailors?: string[]
|
||||
staff?: ExpenseUserOption[]
|
||||
},
|
||||
),
|
||||
)
|
||||
return {
|
||||
expense: expenseData.expense ?? (expenseData as ExpenseDetail),
|
||||
retailors: optionsData.retailors,
|
||||
users: optionsData.users,
|
||||
receipt_url: expenseData.expense?.receipt_url ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExpense(
|
||||
expenseId: number,
|
||||
formData: FormData,
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
return fetchMultipartJson(`/api/v1/administrator/expenses/${expenseId}`, formData)
|
||||
const payload = new FormData()
|
||||
formData.forEach((value, key) => {
|
||||
payload.append(key, value)
|
||||
})
|
||||
payload.set('_method', 'PUT')
|
||||
return fetchMultipartJson(`/api/v1/administrator/expenses/${expenseId}`, payload)
|
||||
}
|
||||
|
||||
export async function updateExpenseStatus(payload: {
|
||||
id: number
|
||||
status: 'approved' | 'denied'
|
||||
}): Promise<{ success?: boolean; error?: string }> {
|
||||
return apiFetch(`/api/v1/administrator/expenses/update-status`, {
|
||||
return apiFetch(`/api/v1/administrator/expenses/${payload.id}/status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({ status: payload.status }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { apiFetch } from './http'
|
||||
|
||||
type TrophyApiEnvelope<T> = {
|
||||
ok?: boolean
|
||||
data?: T
|
||||
}
|
||||
|
||||
export type TrophyStudent = {
|
||||
student_id?: number
|
||||
class_section_id?: number
|
||||
name?: string
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
school_id?: string | null
|
||||
gender?: string | null
|
||||
fall_score?: number | null
|
||||
spring_score?: number | null
|
||||
year_score?: number | null
|
||||
projected_trophy?: boolean
|
||||
predicted?: boolean
|
||||
actual?: boolean
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type TrophyProjectionClassResult = {
|
||||
section_id: number
|
||||
section_name: string
|
||||
students: TrophyStudent[]
|
||||
threshold: number | null
|
||||
trophy_count: number
|
||||
student_count: number
|
||||
scored_count: number
|
||||
method: string
|
||||
boys: number
|
||||
girls: number
|
||||
trophy_boys: number
|
||||
trophy_girls: number
|
||||
pct_boys: number
|
||||
pct_girls: number
|
||||
pct_trophy_boys: number
|
||||
pct_trophy_girls: number
|
||||
pct_trophy_total: number
|
||||
}
|
||||
|
||||
export type TrophyProjectionPayload = {
|
||||
selected_year: string
|
||||
selected_percentile: number
|
||||
years: string[]
|
||||
class_results: TrophyProjectionClassResult[]
|
||||
summary: Record<string, number>
|
||||
}
|
||||
|
||||
export type TrophyWinnersClassResult = {
|
||||
section_id: number
|
||||
section_name: string
|
||||
threshold: number | null
|
||||
winners: TrophyStudent[]
|
||||
student_count: number
|
||||
boys: number
|
||||
girls: number
|
||||
trophy_boys: number
|
||||
trophy_girls: number
|
||||
pct_boys: number
|
||||
pct_girls: number
|
||||
pct_trophy_boys: number
|
||||
pct_trophy_girls: number
|
||||
pct_trophy_total: number
|
||||
}
|
||||
|
||||
export type TrophyWinnersPayload = {
|
||||
selected_year: string
|
||||
selected_percentile: number
|
||||
years: string[]
|
||||
class_results: TrophyWinnersClassResult[]
|
||||
summary: Record<string, number>
|
||||
}
|
||||
|
||||
export type TrophyFinalClassResult = {
|
||||
section_id: number
|
||||
section_name: string
|
||||
students: TrophyStudent[]
|
||||
student_count: number
|
||||
fall_threshold: number | null
|
||||
year_threshold: number | null
|
||||
predicted_count: number
|
||||
actual_count: number
|
||||
confirmed: number
|
||||
surprises: number
|
||||
missed: number
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
export type TrophyGenderSummary = {
|
||||
total: number
|
||||
boys: number
|
||||
girls: number
|
||||
other: number
|
||||
pct_boys: number
|
||||
pct_girls: number
|
||||
pct_other: number
|
||||
}
|
||||
|
||||
export type TrophyFinalPayload = {
|
||||
selected_year: string
|
||||
selected_percentile: number
|
||||
years: string[]
|
||||
class_results: TrophyFinalClassResult[]
|
||||
summary: Record<string, number>
|
||||
winner_gender_summary: TrophyGenderSummary
|
||||
all_gender_summary: TrophyGenderSummary
|
||||
pass_gender_summary: TrophyGenderSummary
|
||||
winner_stickers: Array<{ name: string; section: string }>
|
||||
}
|
||||
|
||||
type TrophyFilters = {
|
||||
school_year?: string
|
||||
percentile?: string | number
|
||||
}
|
||||
|
||||
function withQuery(path: string, params?: TrophyFilters) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.school_year) qs.set('school_year', String(params.school_year))
|
||||
if (params?.percentile !== undefined && String(params.percentile).trim() !== '') {
|
||||
qs.set('percentile', String(params.percentile))
|
||||
}
|
||||
return qs.size > 0 ? `${path}?${qs.toString()}` : path
|
||||
}
|
||||
|
||||
async function fetchTrophyPayload<T>(path: string, params?: TrophyFilters): Promise<T> {
|
||||
const res = await apiFetch<TrophyApiEnvelope<T>>(withQuery(path, params))
|
||||
return (res.data ?? {}) as T
|
||||
}
|
||||
|
||||
export function fetchTrophyProjection(params?: TrophyFilters) {
|
||||
return fetchTrophyPayload<TrophyProjectionPayload>('/api/v1/administrator/trophy', params)
|
||||
}
|
||||
|
||||
export function fetchTrophyWinners(params?: TrophyFilters) {
|
||||
return fetchTrophyPayload<TrophyWinnersPayload>('/api/v1/administrator/trophy/winners', params)
|
||||
}
|
||||
|
||||
export function fetchTrophyFinal(params?: TrophyFilters) {
|
||||
return fetchTrophyPayload<TrophyFinalPayload>('/api/v1/administrator/trophy/final', params)
|
||||
}
|
||||
@@ -639,6 +639,7 @@ export type UserListRow = {
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
email?: string | null
|
||||
school_id?: string | number | null
|
||||
status?: string | null
|
||||
cellphone?: string | null
|
||||
gender?: string | null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../api/http'
|
||||
import { fetchNavMenu } from '../api/session'
|
||||
import type { NavItem } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
@@ -8,6 +9,25 @@ import {
|
||||
spaPathFromCiUrl,
|
||||
} from '../lib/ciSpaPaths'
|
||||
|
||||
const navLabelCollator = new Intl.Collator(undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
})
|
||||
|
||||
function compareNavItemsByLabel(a: NavItem, b: NavItem): number {
|
||||
const labelResult = navLabelCollator.compare(a.label?.trim() || '', b.label?.trim() || '')
|
||||
return labelResult || a.id - b.id
|
||||
}
|
||||
|
||||
function sortNavTree(items: NavItem[]): NavItem[] {
|
||||
return [...items]
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: sortNavTree(item.children ?? []),
|
||||
}))
|
||||
.sort(compareNavItemsByLabel)
|
||||
}
|
||||
|
||||
function normalizeNavItems(payload: unknown): NavItem[] {
|
||||
const rawItems = Array.isArray(payload)
|
||||
? (payload as NavItem[])
|
||||
@@ -20,7 +40,7 @@ function normalizeNavItems(payload: unknown): NavItem[] {
|
||||
|
||||
if (rawItems.length === 0) return []
|
||||
if (rawItems.some((item) => Array.isArray(item.children) && item.children.length > 0)) {
|
||||
return rawItems
|
||||
return sortNavTree(rawItems)
|
||||
}
|
||||
|
||||
const byId = new Map<number, NavItem>()
|
||||
@@ -38,7 +58,7 @@ function normalizeNavItems(payload: unknown): NavItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
return tree
|
||||
return sortNavTree(tree)
|
||||
}
|
||||
|
||||
function branchContainsPath(item: NavItem, pathname: string): boolean {
|
||||
@@ -59,8 +79,6 @@ function NavTree({ items, pathname }: { items: NavItem[]; pathname: string }) {
|
||||
|
||||
function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pathname: string }) {
|
||||
const enabled = item.is_enabled !== 0
|
||||
if (!enabled) return null
|
||||
|
||||
const label = item.label?.trim() || 'Menu'
|
||||
const url = item.url?.trim() || ''
|
||||
const external = isExternalNavUrl(url)
|
||||
@@ -71,7 +89,7 @@ function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pa
|
||||
<i className={`${item.icon_class} me-2`} aria-hidden />
|
||||
) : null
|
||||
|
||||
const children = (item.children ?? []).filter((c) => c.is_enabled !== 0)
|
||||
const children = sortNavTree((item.children ?? []).filter((c) => c.is_enabled !== 0))
|
||||
const hasChildren = children.length > 0
|
||||
const [open, setOpen] = useState(() => hasChildren && branchContainsPath(item, pathname))
|
||||
|
||||
@@ -81,6 +99,8 @@ function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pa
|
||||
}
|
||||
}, [hasChildren, item, pathname])
|
||||
|
||||
if (!enabled) return null
|
||||
|
||||
const linkInner = (
|
||||
<>
|
||||
{icon}
|
||||
@@ -187,6 +207,17 @@ export function ManagementLayout() {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
if (e instanceof ApiHttpError && e.status === 401) {
|
||||
logout()
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: {
|
||||
from: `${location.pathname}${location.search}${location.hash}`,
|
||||
message: 'Your session expired. Please sign in again.',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
setItems([])
|
||||
setMenuError(e instanceof Error ? e.message : 'Unable to load menu.')
|
||||
}
|
||||
@@ -195,7 +226,7 @@ export function ManagementLayout() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [location.hash, location.pathname, location.search, logout, navigate])
|
||||
|
||||
const navTree = useMemo(() => normalizeNavItems(items), [items])
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ export function LoginPage() {
|
||||
const locState = location.state as { from?: string; registered?: boolean; message?: string } | null
|
||||
|
||||
const from = locState?.from ?? '/app/home'
|
||||
const flashSuccess =
|
||||
locState?.registered === true ? locState?.message ?? 'Registration submitted.' : null
|
||||
const flashMessage = locState?.message ?? null
|
||||
const flashVariant = locState?.registered === true ? 'success' : 'warning'
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -101,9 +101,9 @@ export function LoginPage() {
|
||||
Login to Your Account
|
||||
</h3>
|
||||
|
||||
{flashSuccess ? (
|
||||
<div className="alert alert-success text-center" role="alert">
|
||||
{flashSuccess}
|
||||
{flashMessage ? (
|
||||
<div className={`alert alert-${flashVariant} text-center`} role="alert">
|
||||
{flashMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -20,6 +20,59 @@ type NavFormState = {
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
|
||||
const navBuilderCollator = new Intl.Collator(undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
})
|
||||
|
||||
function compareLabels(a: string | null | undefined, b: string | null | undefined): number {
|
||||
return navBuilderCollator.compare(a?.trim() || '', b?.trim() || '')
|
||||
}
|
||||
|
||||
function navParentId(item: NavBuilderItem): number | null {
|
||||
return item.menu_parent_id ?? item.parent_id ?? null
|
||||
}
|
||||
|
||||
function compareBuilderItems(a: NavBuilderItem, b: NavBuilderItem): number {
|
||||
const parentResult = compareLabels(a.parent_label ?? '', b.parent_label ?? '')
|
||||
const labelResult = compareLabels(a.label, b.label)
|
||||
return parentResult || labelResult || a.id - b.id
|
||||
}
|
||||
|
||||
function sortBuilderItems(items: NavBuilderItem[]): NavBuilderItem[] {
|
||||
return [...items].sort(compareBuilderItems)
|
||||
}
|
||||
|
||||
function sortParentOptions(options: NavBuilderData['parentOptions']): NavBuilderData['parentOptions'] {
|
||||
return [...options].sort((a, b) => compareLabels(a.label, b.label) || a.id - b.id)
|
||||
}
|
||||
|
||||
function normalizeBuilderData(data: NavBuilderData): NavBuilderData {
|
||||
return {
|
||||
...data,
|
||||
items: sortBuilderItems(data.items ?? []),
|
||||
parentOptions: sortParentOptions(data.parentOptions ?? []),
|
||||
}
|
||||
}
|
||||
|
||||
function alphabeticalOrdersByParent(items: NavBuilderItem[]): Record<string, number> {
|
||||
const groups = new Map<string, NavBuilderItem[]>()
|
||||
for (const item of items) {
|
||||
const key = String(navParentId(item) ?? 'root')
|
||||
groups.set(key, [...(groups.get(key) ?? []), item])
|
||||
}
|
||||
|
||||
const orders: Record<string, number> = {}
|
||||
for (const group of groups.values()) {
|
||||
const sorted = [...group].sort((a, b) => compareLabels(a.label, b.label) || a.id - b.id)
|
||||
sorted.forEach((item, index) => {
|
||||
orders[String(item.id)] = index + 1
|
||||
})
|
||||
}
|
||||
return orders
|
||||
}
|
||||
|
||||
const blankForm: NavFormState = {
|
||||
id: '',
|
||||
menu_parent_id: '',
|
||||
@@ -47,7 +100,7 @@ export function NavBuilderPage() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchNavBuilderData()
|
||||
setPayload(res.data?.data ?? { items: [], roles: [], parentOptions: [] })
|
||||
setPayload(normalizeBuilderData(res.data?.data ?? { items: [], roles: [], parentOptions: [] }))
|
||||
setMessages([])
|
||||
} catch (error) {
|
||||
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Failed to load menu items.' }])
|
||||
@@ -60,8 +113,10 @@ export function NavBuilderPage() {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const sortedItems = useMemo(() => sortBuilderItems(payload.items), [payload.items])
|
||||
|
||||
const filteredParentOptions = useMemo(
|
||||
() => payload.parentOptions.filter((option) => String(option.id) !== form.id),
|
||||
() => sortParentOptions(payload.parentOptions.filter((option) => String(option.id) !== form.id)),
|
||||
[form.id, payload.parentOptions],
|
||||
)
|
||||
|
||||
@@ -77,7 +132,7 @@ export function NavBuilderPage() {
|
||||
url: form.url.trim() || null,
|
||||
icon_class: form.icon_class.trim() || null,
|
||||
target: form.target.trim() || null,
|
||||
sort_order: Number(form.sort_order) || 0,
|
||||
sort_order: 0,
|
||||
is_enabled: form.is_enabled,
|
||||
roles: form.roles.map(Number).filter((id) => id > 0),
|
||||
})
|
||||
@@ -105,13 +160,11 @@ export function NavBuilderPage() {
|
||||
}
|
||||
|
||||
async function onSaveOrder() {
|
||||
const orders = Object.fromEntries(
|
||||
payload.items.map((item) => [String(item.id), Number(item.sort_order ?? item.order ?? 0)]),
|
||||
)
|
||||
const orders = alphabeticalOrdersByParent(payload.items)
|
||||
setMessages([])
|
||||
try {
|
||||
await reorderNavBuilderItems(orders)
|
||||
setMessages([{ type: 'success', message: 'Menu order saved.' }])
|
||||
setMessages([{ type: 'success', message: 'Alphabetical menu order saved.' }])
|
||||
await load()
|
||||
} catch (error) {
|
||||
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to reorder menu items.' }])
|
||||
@@ -133,6 +186,8 @@ export function NavBuilderPage() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const alphabeticalOrders = useMemo(() => alphabeticalOrdersByParent(sortedItems), [sortedItems])
|
||||
|
||||
const editingItem = form.id
|
||||
? payload.items.find((item) => String(item.id) === form.id) ?? null
|
||||
: null
|
||||
@@ -154,7 +209,7 @@ export function NavBuilderPage() {
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Current Menu</span>
|
||||
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void onSaveOrder()}>
|
||||
Save order
|
||||
Save alphabetical order
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
@@ -167,7 +222,7 @@ export function NavBuilderPage() {
|
||||
<th>URL</th>
|
||||
<th>Parent</th>
|
||||
<th>Roles</th>
|
||||
<th>Order</th>
|
||||
<th>Alphabetical position</th>
|
||||
<th>Enabled</th>
|
||||
<th>Depth</th>
|
||||
</tr>
|
||||
@@ -182,7 +237,7 @@ export function NavBuilderPage() {
|
||||
<td colSpan={8} className="text-center text-muted">No menu items found.</td>
|
||||
</tr>
|
||||
) : (
|
||||
payload.items.map((item) => (
|
||||
sortedItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="text-nowrap">
|
||||
<button className="btn btn-sm btn-outline-primary me-1" type="button" onClick={() => editItem(item)}>
|
||||
@@ -198,7 +253,7 @@ export function NavBuilderPage() {
|
||||
{item.icon_class ? <code className="ms-2 small">{item.icon_class}</code> : null}
|
||||
</td>
|
||||
<td>{item.url ? <code>{item.url}</code> : <span className="text-muted">-</span>}</td>
|
||||
<td>{parentLabel(item, payload.items)}</td>
|
||||
<td>{parentLabel(item, sortedItems)}</td>
|
||||
<td>
|
||||
{(item.roles?.names ?? []).length > 0 ? (
|
||||
item.roles?.names?.map((roleName) => (
|
||||
@@ -211,21 +266,12 @@ export function NavBuilderPage() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="form-control form-control-sm nav-builder-order"
|
||||
type="number"
|
||||
value={item.sort_order ?? item.order ?? 0}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value) || 0
|
||||
setPayload((current) => ({
|
||||
...current,
|
||||
items: current.items.map((row) => (row.id === item.id ? { ...row, sort_order: next } : row)),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
<span className="badge bg-light text-dark">
|
||||
{alphabeticalOrders[String(item.id)] ?? '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{Boolean(item.is_enabled ?? item.enabled) ? (
|
||||
{(item.is_enabled ?? item.enabled) ? (
|
||||
<span className="badge bg-success">Yes</span>
|
||||
) : (
|
||||
<span className="badge bg-light text-dark">No</span>
|
||||
@@ -312,15 +358,8 @@ export function NavBuilderPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-4 mb-2">
|
||||
<label className="form-label" htmlFor="sort_order">Order</label>
|
||||
<input
|
||||
className="form-control"
|
||||
type="number"
|
||||
name="sort_order"
|
||||
id="sort_order"
|
||||
value={form.sort_order}
|
||||
onChange={(event) => setForm((current) => ({ ...current, sort_order: event.target.value }))}
|
||||
/>
|
||||
<label className="form-label">Order</label>
|
||||
<div className="form-control bg-light text-muted">Alphabetical</div>
|
||||
</div>
|
||||
<div className="col-md-4 mb-2 d-flex align-items-end">
|
||||
<div className="form-check">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function DiscountApplyVoucherPage() {
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th>Parent ID</th>
|
||||
<th>School ID</th>
|
||||
<th>Parent Name</th>
|
||||
<th>Email</th>
|
||||
<th>Has Discount?</th>
|
||||
|
||||
@@ -143,7 +143,7 @@ export function ReverseDiscountPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Select</th>
|
||||
<th>Parent ID</th>
|
||||
<th>School ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Has discount?</th>
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
fetchTrophyFinal,
|
||||
fetchTrophyProjection,
|
||||
fetchTrophyWinners,
|
||||
type TrophyFinalPayload,
|
||||
type TrophyProjectionPayload,
|
||||
type TrophyStudent,
|
||||
type TrophyWinnersClassResult,
|
||||
type TrophyWinnersPayload,
|
||||
} from '../../api/trophy'
|
||||
|
||||
function formatScore(value?: number | null) {
|
||||
return value == null || Number.isNaN(value) ? '—' : value.toFixed(1)
|
||||
}
|
||||
|
||||
function isMale(gender?: string | null) {
|
||||
const normalized = String(gender ?? '').trim().toLowerCase()
|
||||
return ['male', 'm', 'boy', 'boys'].includes(normalized)
|
||||
}
|
||||
|
||||
function buildSearch(params: { school_year?: string; percentile?: string | number }) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.school_year) qs.set('school_year', String(params.school_year))
|
||||
if (params.percentile !== undefined && String(params.percentile).trim() !== '') {
|
||||
qs.set('percentile', String(params.percentile))
|
||||
}
|
||||
const value = qs.toString()
|
||||
return value ? `?${value}` : ''
|
||||
}
|
||||
|
||||
function TrophyHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="mb-1">{title}</h2>
|
||||
<p className="text-muted mb-0">{subtitle}</p>
|
||||
</div>
|
||||
{actions ? <div className="d-flex gap-2 flex-wrap">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrophyFilters({
|
||||
years,
|
||||
selectedYear,
|
||||
selectedPercentile,
|
||||
onSubmit,
|
||||
}: {
|
||||
years: string[]
|
||||
selectedYear: string
|
||||
selectedPercentile: number
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<form className="row g-3 align-items-end mb-4" onSubmit={onSubmit}>
|
||||
<div className="col-md-4 col-lg-3">
|
||||
<label className="form-label">School Year</label>
|
||||
<select className="form-select" name="school_year" defaultValue={selectedYear}>
|
||||
{(years.length > 0 ? years : [selectedYear]).map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-4 col-lg-3">
|
||||
<label className="form-label">Percentile</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
className="form-control"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
step="1"
|
||||
name="percentile"
|
||||
defaultValue={Math.round(selectedPercentile)}
|
||||
/>
|
||||
<span className="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4 col-lg-2">
|
||||
<button className="btn btn-primary w-100" type="submit">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function useTrophySearchParams() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const percentile = searchParams.get('percentile') ?? ''
|
||||
|
||||
function applyFilters(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const next = new URLSearchParams()
|
||||
const year = String(formData.get('school_year') ?? '').trim()
|
||||
const pct = String(formData.get('percentile') ?? '').trim()
|
||||
if (year) next.set('school_year', year)
|
||||
if (pct) next.set('percentile', pct)
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
return {
|
||||
schoolYear,
|
||||
percentile,
|
||||
applyFilters,
|
||||
}
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
tone = 'primary',
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
detail?: string
|
||||
tone?: 'primary' | 'success' | 'warning' | 'secondary' | 'info' | 'dark'
|
||||
}) {
|
||||
return (
|
||||
<div className="col-6 col-lg-2">
|
||||
<div className={`card border-${tone} shadow-sm h-100`}>
|
||||
<div className="card-body py-3">
|
||||
<div className={`small text-${tone} text-uppercase fw-semibold mb-1`}>{label}</div>
|
||||
<div className="fs-3 fw-semibold">{value}</div>
|
||||
{detail ? <div className="small text-muted mt-1">{detail}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentGenderBadge({ gender }: { gender?: string | null }) {
|
||||
const male = isMale(gender)
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
style={{ backgroundColor: male ? '#4A90E2' : '#E47AB0' }}
|
||||
>
|
||||
{male ? 'M' : 'F'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrophyProjectionPage() {
|
||||
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
|
||||
const [data, setData] = useState<TrophyProjectionPayload | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setError(null)
|
||||
fetchTrophyProjection({
|
||||
school_year: schoolYear || undefined,
|
||||
percentile: percentile || undefined,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!cancelled) setData(payload)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to load trophy projection.')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, percentile])
|
||||
|
||||
const selectedYear = data?.selected_year ?? schoolYear
|
||||
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
|
||||
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
|
||||
const summary = data?.summary ?? {}
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<TrophyHeader
|
||||
title="Trophy Projections"
|
||||
subtitle="Fall scores determine the projected year-end trophy threshold for each class."
|
||||
actions={
|
||||
<>
|
||||
<Link className="btn btn-warning" to={`/app/administrator/trophy_winners${search}`}>
|
||||
Reveal Winners
|
||||
</Link>
|
||||
<Link className="btn btn-success" to={`/app/administrator/trophy_final${search}`}>
|
||||
Final vs Predicted
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<TrophyFilters
|
||||
years={data?.years ?? []}
|
||||
selectedYear={selectedYear}
|
||||
selectedPercentile={selectedPercentile}
|
||||
onSubmit={applyFilters}
|
||||
/>
|
||||
|
||||
<div className="row g-3 mb-4">
|
||||
<SummaryCard label="Classes" value={summary.classes ?? 0} tone="secondary" />
|
||||
<SummaryCard label="Students" value={summary.students ?? 0} tone="primary" />
|
||||
<SummaryCard label="Scored" value={summary.scored ?? 0} detail={`${summary.pct_scored ?? 0}% roster`} tone="success" />
|
||||
<SummaryCard label="Trophies" value={summary.trophies ?? 0} detail={`${summary.pct_trophies ?? 0}% roster`} tone="warning" />
|
||||
<SummaryCard label="Boys" value={summary.boys ?? 0} detail={`${summary.pct_boys ?? 0}%`} tone="info" />
|
||||
<SummaryCard label="Girls" value={summary.girls ?? 0} detail={`${summary.pct_girls ?? 0}%`} tone="dark" />
|
||||
</div>
|
||||
|
||||
{data && data.class_results.length === 0 ? (
|
||||
<div className="alert alert-info">No class or fall-score data found for the selected school year.</div>
|
||||
) : null}
|
||||
|
||||
{data?.class_results?.length ? (
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header bg-white fw-semibold">Class Breakdown</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th className="text-center">Students</th>
|
||||
<th className="text-center">Scored</th>
|
||||
<th className="text-center">Trophies</th>
|
||||
<th className="text-center">Boys</th>
|
||||
<th className="text-center">Girls</th>
|
||||
<th className="text-center">Trophy Boys</th>
|
||||
<th className="text-center">Trophy Girls</th>
|
||||
<th className="text-end">Threshold</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.class_results.map((row) => (
|
||||
<tr key={row.section_id}>
|
||||
<td className="fw-semibold">{row.section_name}</td>
|
||||
<td className="text-center">{row.student_count}</td>
|
||||
<td className="text-center">{row.scored_count}</td>
|
||||
<td className="text-center">
|
||||
<span className="badge text-bg-warning">{row.trophy_count}</span>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{row.boys} <span className="text-muted small">({row.pct_boys}%)</span>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{row.girls} <span className="text-muted small">({row.pct_girls}%)</span>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{row.trophy_boys} <span className="text-muted small">({row.pct_trophy_boys}%)</span>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{row.trophy_girls} <span className="text-muted small">({row.pct_trophy_girls}%)</span>
|
||||
</td>
|
||||
<td className="text-end text-muted">{formatScore(row.threshold)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WinnersTable({
|
||||
rows,
|
||||
}: {
|
||||
rows: TrophyWinnersClassResult[]
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{rows.map((section) => (
|
||||
<section key={section.section_id} className="card shadow-sm mb-4">
|
||||
<div className="card-header d-flex flex-wrap justify-content-between gap-2">
|
||||
<div className="fw-semibold">{section.section_name}</div>
|
||||
<div className="small text-muted">
|
||||
Threshold {formatScore(section.threshold)} | {section.winners.length}/{section.student_count} winners
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th style={{ width: 70 }}>#</th>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 90 }}>Gender</th>
|
||||
<th style={{ width: 140 }}>School ID</th>
|
||||
<th className="text-end" style={{ width: 120 }}>Fall</th>
|
||||
<th className="text-end" style={{ width: 120 }}>Spring</th>
|
||||
<th className="text-end" style={{ width: 120 }}>Year</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.winners.map((student, index) => (
|
||||
<tr key={`${section.section_id}-${student.student_id ?? index}`}>
|
||||
<td>{index + 1}</td>
|
||||
<td className="fw-semibold">{student.name}</td>
|
||||
<td><StudentGenderBadge gender={student.gender} /></td>
|
||||
<td>{student.school_id ?? '—'}</td>
|
||||
<td className="text-end">{formatScore(student.fall_score)}</td>
|
||||
<td className="text-end">{formatScore(student.spring_score)}</td>
|
||||
<td className="text-end">{formatScore(student.year_score)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrophyWinnersPage() {
|
||||
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
|
||||
const [data, setData] = useState<TrophyWinnersPayload | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setError(null)
|
||||
fetchTrophyWinners({
|
||||
school_year: schoolYear || undefined,
|
||||
percentile: percentile || undefined,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!cancelled) setData(payload)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to load trophy winners.')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, percentile])
|
||||
|
||||
const selectedYear = data?.selected_year ?? schoolYear
|
||||
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
|
||||
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
|
||||
const summary = data?.summary ?? {}
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<TrophyHeader
|
||||
title="Trophy Winners"
|
||||
subtitle={`${selectedYear || 'Current year'} projected trophy winners at the ${Math.round(selectedPercentile)}th percentile.`}
|
||||
actions={
|
||||
<>
|
||||
<Link className="btn btn-outline-secondary" to={`/app/administrator/trophy${search}`}>
|
||||
Back to Projection
|
||||
</Link>
|
||||
<Link className="btn btn-success" to={`/app/administrator/trophy_final${search}`}>
|
||||
Final vs Predicted
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<TrophyFilters
|
||||
years={data?.years ?? []}
|
||||
selectedYear={selectedYear}
|
||||
selectedPercentile={selectedPercentile}
|
||||
onSubmit={applyFilters}
|
||||
/>
|
||||
|
||||
<div className="row g-3 mb-4">
|
||||
<SummaryCard label="Classes" value={summary.classes ?? 0} tone="secondary" />
|
||||
<SummaryCard label="Students" value={summary.students ?? 0} tone="primary" />
|
||||
<SummaryCard label="Winners" value={summary.winners ?? 0} detail={`${summary.pct_winners ?? 0}% roster`} tone="warning" />
|
||||
<SummaryCard label="Trophy Boys" value={summary.trophy_boys ?? 0} detail={`${summary.pct_trophy_boys ?? 0}% boys`} tone="info" />
|
||||
<SummaryCard label="Trophy Girls" value={summary.trophy_girls ?? 0} detail={`${summary.pct_trophy_girls ?? 0}% girls`} tone="dark" />
|
||||
</div>
|
||||
|
||||
{data && data.class_results.length === 0 ? (
|
||||
<div className="alert alert-info">No projected trophy winners found.</div>
|
||||
) : null}
|
||||
|
||||
{data?.class_results?.length ? <WinnersTable rows={data.class_results} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status?: string }) {
|
||||
if (status === 'confirmed') return <span className="badge bg-success">Confirmed</span>
|
||||
if (status === 'surprise') return <span className="badge bg-info text-dark">Surprise</span>
|
||||
if (status === 'missed') return <span className="badge text-bg-warning">Missed</span>
|
||||
return <span className="badge bg-light text-dark border">None</span>
|
||||
}
|
||||
|
||||
function FinalStatusRows({ students }: { students: TrophyStudent[] }) {
|
||||
return students.filter((student) => student.status && student.status !== 'none')
|
||||
}
|
||||
|
||||
export function TrophyFinalPage() {
|
||||
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
|
||||
const [data, setData] = useState<TrophyFinalPayload | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setError(null)
|
||||
fetchTrophyFinal({
|
||||
school_year: schoolYear || undefined,
|
||||
percentile: percentile || undefined,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!cancelled) setData(payload)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to load final trophy report.')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [schoolYear, percentile])
|
||||
|
||||
const selectedYear = data?.selected_year ?? schoolYear
|
||||
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
|
||||
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
|
||||
const summary = data?.summary ?? {}
|
||||
const winnerGender = data?.winner_gender_summary
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<TrophyHeader
|
||||
title="Final vs Predicted"
|
||||
subtitle="Compares fall-score trophy projections with year-end trophy outcomes."
|
||||
actions={
|
||||
<>
|
||||
<Link className="btn btn-outline-secondary" to={`/app/administrator/trophy${search}`}>
|
||||
Projection
|
||||
</Link>
|
||||
<Link className="btn btn-warning" to={`/app/administrator/trophy_winners${search}`}>
|
||||
Winners
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<TrophyFilters
|
||||
years={data?.years ?? []}
|
||||
selectedYear={selectedYear}
|
||||
selectedPercentile={selectedPercentile}
|
||||
onSubmit={applyFilters}
|
||||
/>
|
||||
|
||||
<div className="row g-3 mb-4">
|
||||
<SummaryCard label="Predicted" value={summary.predicted ?? 0} detail={`${summary.pct_predicted ?? 0}% roster`} tone="primary" />
|
||||
<SummaryCard label="Actual" value={summary.actual ?? 0} detail={`${summary.pct_actual ?? 0}% roster`} tone="warning" />
|
||||
<SummaryCard label="Confirmed" value={summary.confirmed ?? 0} detail={`${summary.pct_confirmed_of_predicted ?? 0}% of predicted`} tone="success" />
|
||||
<SummaryCard label="Surprises" value={summary.surprises ?? 0} tone="info" />
|
||||
<SummaryCard label="Missed" value={summary.missed ?? 0} tone="dark" />
|
||||
<SummaryCard label="Accuracy" value={`${summary.accuracy ?? 0}%`} tone="secondary" />
|
||||
</div>
|
||||
|
||||
{winnerGender ? (
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header bg-white fw-semibold">Winner Gender Breakdown</div>
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
<SummaryCard label="Total Winners" value={winnerGender.total} tone="secondary" />
|
||||
<SummaryCard label="Boys" value={winnerGender.boys} detail={`${winnerGender.pct_boys}%`} tone="info" />
|
||||
<SummaryCard label="Girls" value={winnerGender.girls} detail={`${winnerGender.pct_girls}%`} tone="dark" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data && data.class_results.length === 0 ? (
|
||||
<div className="alert alert-info">No data found for the selected school year.</div>
|
||||
) : null}
|
||||
|
||||
{data?.class_results?.map((section) => {
|
||||
const rows = FinalStatusRows({ students: section.students })
|
||||
return (
|
||||
<section key={section.section_id} className="card shadow-sm mb-4">
|
||||
<div className="card-header d-flex flex-wrap justify-content-between gap-2">
|
||||
<div className="fw-semibold">{section.section_name}</div>
|
||||
<div className="small text-muted">
|
||||
Fall ≥ {formatScore(section.fall_threshold)} | Year ≥ {formatScore(section.year_threshold)} | Accuracy {section.accuracy}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 90 }}>Gender</th>
|
||||
<th className="text-end" style={{ width: 110 }}>Fall</th>
|
||||
<th className="text-end" style={{ width: 110 }}>Spring</th>
|
||||
<th className="text-end" style={{ width: 110 }}>Year</th>
|
||||
<th className="text-center" style={{ width: 110 }}>Predicted</th>
|
||||
<th className="text-center" style={{ width: 110 }}>Actual</th>
|
||||
<th className="text-center" style={{ width: 120 }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center text-muted py-4">
|
||||
No trophy changes in this class.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((student) => (
|
||||
<tr key={`${section.section_id}-${student.student_id}`}>
|
||||
<td className="fw-semibold">{student.name}</td>
|
||||
<td><StudentGenderBadge gender={student.gender} /></td>
|
||||
<td className="text-end">{formatScore(student.fall_score)}</td>
|
||||
<td className="text-end">{formatScore(student.spring_score)}</td>
|
||||
<td className="text-end">{formatScore(student.year_score)}</td>
|
||||
<td className="text-center">{student.predicted ? 'Yes' : 'No'}</td>
|
||||
<td className="text-center">{student.actual ? 'Yes' : 'No'}</td>
|
||||
<td className="text-center"><StatusBadge status={student.status} /></td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user