From c4d7a06a17a41f6e335b79a3769edf741133766c Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Jun 2026 01:51:12 -0400 Subject: [PATCH] fix financial and certificates --- src/App.tsx | 26 + src/api/certificates.ts | 154 ++++- src/api/invoices.ts | 4 +- src/api/paymentManagement.ts | 61 +- src/api/refunds.ts | 6 +- src/api/session.ts | 146 +++- src/api/trophy.ts | 144 ++++ src/api/types.ts | 1 + src/layout/ManagementLayout.tsx | 43 +- src/pages/LoginPage.tsx | 10 +- src/pages/NavBuilderPage.tsx | 105 ++- .../certificates/CertificatesAuditLogPage.tsx | 161 +++++ src/pages/certificates/CertificatesPage.tsx | 649 ++++++++++-------- .../discounts/DiscountApplyVoucherPage.tsx | 2 +- src/pages/discounts/ReverseDiscountPage.tsx | 2 +- src/pages/trophy/TrophyPages.tsx | 549 +++++++++++++++ 16 files changed, 1689 insertions(+), 374 deletions(-) create mode 100644 src/api/trophy.ts create mode 100644 src/pages/certificates/CertificatesAuditLogPage.tsx create mode 100644 src/pages/trophy/TrophyPages.tsx diff --git a/src/App.tsx b/src/App.tsx index d1efd67..7b715f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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={} /> } /> + } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/api/certificates.ts b/src/api/certificates.ts index 1794c24..43153d4 100644 --- a/src/api/certificates.ts +++ b/src/api/certificates.ts @@ -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 + default_group_key: string | null } -export async function fetchCertFormOptions(params?: { - school_year?: string - class_section_id?: string | number -}): Promise> { +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) { 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>(`/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 { + const res = await apiFetch>( + withQuery('/api/v1/administrator/certificates/dashboard', params), + ) + return (res.data ?? {}) as CertificateDashboardPayload +} + +export async function fetchCertificatesAuditLog(params?: { + school_year?: string +}): Promise { + const res = await apiFetch>( + 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 { - 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 { + 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() } diff --git a/src/api/invoices.ts b/src/api/invoices.ts index af1494a..e04dc0c 100644 --- a/src/api/invoices.ts +++ b/src/api/invoices.ts @@ -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 { - return apiFetch(`${BASE}/generate-for-parent`, { + return apiFetch(`${BASE}/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parent_id: parentId }), diff --git a/src/api/paymentManagement.ts b/src/api/paymentManagement.ts index 640bb01..196f62c 100644 --- a/src/api/paymentManagement.ts +++ b/src/api/paymentManagement.ts @@ -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( + `/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 { 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 { 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}`) diff --git a/src/api/refunds.ts b/src/api/refunds.ts index e018dfe..022d8f3 100644 --- a/src/api/refunds.ts +++ b/src/api/refunds.ts @@ -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 @@ -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(`${BASE}/list${suffix}`) + const body = await apiFetch(`${BASE}${suffix}`) return normalizeListPayload(body) } diff --git a/src/api/session.ts b/src/api/session.ts index 9f05218..6d15ebd 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -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 { window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) } -async function fetchMultipartJson(path: string, formData: FormData): Promise { +async function fetchMultipartJson( + path: string, + formData: FormData, + init: { method?: 'POST' | 'PUT' | 'PATCH' } = {}, +): Promise { 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(`/api/v1/users${qs}`) } +let discountParentSchoolIdMapPromise: Promise> | 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> { + if (!discountParentSchoolIdMapPromise) { + discountParentSchoolIdMapPromise = (async () => { + const data = await fetchUsers({ sort: 'lastname', order: 'asc' }) + const map = new Map() + + 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 { + 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 @@ -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( + `/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( `/api/v1/discounts/apply-context${suffix}`, ) - return unwrapData(body) + return enrichDiscountApplyContext(unwrapData(body)) } export async function createDiscountVoucher(payload: Record): Promise<{ ok?: boolean; message?: string }> { @@ -2194,11 +2271,31 @@ export async function fetchExpensesList(params?: { } } +function normalizeExpenseFormOptions( + body: Partial & { + retailors?: string[] + staff?: ExpenseUserOption[] + }, +): ExpenseCreateFormResponse { + return { + retailors: body.retailors ?? [], + users: body.users ?? body.staff ?? [], + } +} + export async function fetchExpenseCreateForm(): Promise { const body = await apiFetch( - '/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 { - const body = await apiFetch( - `/api/v1/administrator/expenses/${expenseId}/edit`, + const [expenseBody, optionsBody] = await Promise.all([ + apiFetch<{ expense?: ExpenseDetail; data?: { expense?: ExpenseDetail } }>( + `/api/v1/administrator/expenses/${expenseId}`, + ), + apiFetch( + '/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 }), }) } diff --git a/src/api/trophy.ts b/src/api/trophy.ts new file mode 100644 index 0000000..a5e1571 --- /dev/null +++ b/src/api/trophy.ts @@ -0,0 +1,144 @@ +import { apiFetch } from './http' + +type TrophyApiEnvelope = { + 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 +} + +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 +} + +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 + 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(path: string, params?: TrophyFilters): Promise { + const res = await apiFetch>(withQuery(path, params)) + return (res.data ?? {}) as T +} + +export function fetchTrophyProjection(params?: TrophyFilters) { + return fetchTrophyPayload('/api/v1/administrator/trophy', params) +} + +export function fetchTrophyWinners(params?: TrophyFilters) { + return fetchTrophyPayload('/api/v1/administrator/trophy/winners', params) +} + +export function fetchTrophyFinal(params?: TrophyFilters) { + return fetchTrophyPayload('/api/v1/administrator/trophy/final', params) +} diff --git a/src/api/types.ts b/src/api/types.ts index 6629c8e..5579ddf 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 diff --git a/src/layout/ManagementLayout.tsx b/src/layout/ManagementLayout.tsx index eb47814..e1b478e 100644 --- a/src/layout/ManagementLayout.tsx +++ b/src/layout/ManagementLayout.tsx @@ -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() @@ -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 ) : 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]) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 14d725e..90a0b0c 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -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 - {flashSuccess ? ( -
- {flashSuccess} + {flashMessage ? ( +
+ {flashMessage}
) : null} diff --git a/src/pages/NavBuilderPage.tsx b/src/pages/NavBuilderPage.tsx index 2ba8a0d..d059a72 100644 --- a/src/pages/NavBuilderPage.tsx +++ b/src/pages/NavBuilderPage.tsx @@ -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 { + const groups = new Map() + for (const item of items) { + const key = String(navParentId(item) ?? 'root') + groups.set(key, [...(groups.get(key) ?? []), item]) + } + + const orders: Record = {} + 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() {
Current Menu
@@ -167,7 +222,7 @@ export function NavBuilderPage() { URL Parent Roles - Order + Alphabetical position Enabled Depth @@ -182,7 +237,7 @@ export function NavBuilderPage() { No menu items found. ) : ( - payload.items.map((item) => ( + sortedItems.map((item) => (
- - 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} + +
+
+
+ + + + + + + + + + + + + + {(data?.records.length ?? 0) === 0 ? ( + + + + ) : ( + data?.records.map((record) => ( + + + + + + + + + + )) + )} + +
Certificate #StudentGradeCert DateSchool YearIssued ByIssued At
+ No certificates issued yet. +
{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. -
-
-
+
+

{section.section_name}

- {error && ( -
- {error} - +
+ + ) +} -
- - {selectedIds.size} student{selectedIds.size !== 1 ? 's' : ''} selected - - +export function CertificatesPage() { + const [searchParams, setSearchParams] = useSearchParams() + const schoolYear = searchParams.get('school_year') ?? '' + const groupKey = searchParams.get('group') ?? '' + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [yearInput, setYearInput] = useState('') + + async function loadDashboard(currentSchoolYear: string) { + setLoading(true) + setError(null) + + try { + const payload = await fetchCertificatesDashboard({ + school_year: currentSchoolYear || undefined, + }) + setData(payload) + setYearInput(payload.school_year || currentSchoolYear) + } catch (e) { + setError(e instanceof ApiHttpError ? e.message : 'Failed to load certificate dashboard.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadDashboard(schoolYear) + }, [schoolYear]) + + const groups = data?.grade_groups ?? [] + const activeGroupKey = + (groupKey && groups.some((group) => group.key === groupKey) ? groupKey : '') || + data?.default_group_key || + groups[0]?.key || + '' + const activeGroup = groups.find((group) => group.key === activeGroupKey) ?? null + + function applySchoolYear(event: FormEvent) { + event.preventDefault() + const next = new URLSearchParams(searchParams) + if (yearInput.trim()) next.set('school_year', yearInput.trim()) + else next.delete('school_year') + next.delete('group') + setSearchParams(next, { replace: true }) + } + + function setActiveGroup(nextGroupKey: string) { + const next = new URLSearchParams(searchParams) + next.set('group', nextGroupKey) + if (schoolYear) next.set('school_year', schoolYear) + setSearchParams(next, { replace: true }) + } + + return ( +
+
+
+
+

+ + Generate Certificates +

+
+ Students are eligible only when the saved final generated decision is Pass.
- - )} - - {!loadingStudents && classId && !hasStudents && ( -
- No active students found for the selected class and school year. + + + Audit Log +
- )} - {!loadingStudents && !classId && ( -
- Select a class above to load its student roster. + {error ?
{error}
: null} + +
+
+ + setYearInput(event.target.value)} + placeholder="e.g. 2024-2025" + /> + +
- )} + + {loading ?
Loading certificates…
: null} + + {!loading && groups.length === 0 ? ( +
No classes found for {data?.school_year || schoolYear || 'this year'}.
+ ) : null} + + {!loading && groups.length > 0 ? ( + <> +
    + {groups.map((group) => ( +
  • + +
  • + ))} +
+ +
+ {activeGroup?.sections.map((section) => ( + loadDashboard(data?.school_year ?? schoolYear)} + /> + ))} +
+ + ) : null} +
+ +
) } diff --git a/src/pages/discounts/DiscountApplyVoucherPage.tsx b/src/pages/discounts/DiscountApplyVoucherPage.tsx index c586356..8008cc0 100644 --- a/src/pages/discounts/DiscountApplyVoucherPage.tsx +++ b/src/pages/discounts/DiscountApplyVoucherPage.tsx @@ -172,7 +172,7 @@ export function DiscountApplyVoucherPage() { aria-label="Select all" /> - Parent ID + School ID Parent Name Email Has Discount? diff --git a/src/pages/discounts/ReverseDiscountPage.tsx b/src/pages/discounts/ReverseDiscountPage.tsx index 1cb0d6c..eb5df7a 100644 --- a/src/pages/discounts/ReverseDiscountPage.tsx +++ b/src/pages/discounts/ReverseDiscountPage.tsx @@ -143,7 +143,7 @@ export function ReverseDiscountPage() { Select - Parent ID + School ID Name Email Has discount? diff --git a/src/pages/trophy/TrophyPages.tsx b/src/pages/trophy/TrophyPages.tsx new file mode 100644 index 0000000..5369591 --- /dev/null +++ b/src/pages/trophy/TrophyPages.tsx @@ -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 ( +
+
+

{title}

+

{subtitle}

+
+ {actions ?
{actions}
: null} +
+ ) +} + +function TrophyFilters({ + years, + selectedYear, + selectedPercentile, + onSubmit, +}: { + years: string[] + selectedYear: string + selectedPercentile: number + onSubmit: (event: FormEvent) => void +}) { + return ( +
+
+ + +
+
+ +
+ + % +
+
+
+ +
+
+ ) +} + +function useTrophySearchParams() { + const [searchParams, setSearchParams] = useSearchParams() + const schoolYear = searchParams.get('school_year') ?? '' + const percentile = searchParams.get('percentile') ?? '' + + function applyFilters(event: FormEvent) { + 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 ( +
+
+
+
{label}
+
{value}
+ {detail ?
{detail}
: null} +
+
+
+ ) +} + +function StudentGenderBadge({ gender }: { gender?: string | null }) { + const male = isMale(gender) + return ( + + {male ? 'M' : 'F'} + + ) +} + +export function TrophyProjectionPage() { + const { schoolYear, percentile, applyFilters } = useTrophySearchParams() + const [data, setData] = useState(null) + const [error, setError] = useState(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 ( +
+ + + Reveal Winners + + + Final vs Predicted + + + } + /> + + {error ?
{error}
: null} + + + +
+ + + + + + +
+ + {data && data.class_results.length === 0 ? ( +
No class or fall-score data found for the selected school year.
+ ) : null} + + {data?.class_results?.length ? ( +
+
Class Breakdown
+
+ + + + + + + + + + + + + + + + {data.class_results.map((row) => ( + + + + + + + + + + + + ))} + +
ClassStudentsScoredTrophiesBoysGirlsTrophy BoysTrophy GirlsThreshold
{row.section_name}{row.student_count}{row.scored_count} + {row.trophy_count} + + {row.boys} ({row.pct_boys}%) + + {row.girls} ({row.pct_girls}%) + + {row.trophy_boys} ({row.pct_trophy_boys}%) + + {row.trophy_girls} ({row.pct_trophy_girls}%) + {formatScore(row.threshold)}
+
+
+ ) : null} +
+ ) +} + +function WinnersTable({ + rows, +}: { + rows: TrophyWinnersClassResult[] +}) { + return ( + <> + {rows.map((section) => ( +
+
+
{section.section_name}
+
+ Threshold {formatScore(section.threshold)} | {section.winners.length}/{section.student_count} winners +
+
+
+ + + + + + + + + + + + + + {section.winners.map((student, index) => ( + + + + + + + + + + ))} + +
#NameGenderSchool IDFallSpringYear
{index + 1}{student.name}{student.school_id ?? '—'}{formatScore(student.fall_score)}{formatScore(student.spring_score)}{formatScore(student.year_score)}
+
+
+ ))} + + ) +} + +export function TrophyWinnersPage() { + const { schoolYear, percentile, applyFilters } = useTrophySearchParams() + const [data, setData] = useState(null) + const [error, setError] = useState(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 ( +
+ + + Back to Projection + + + Final vs Predicted + + + } + /> + + {error ?
{error}
: null} + + + +
+ + + + + +
+ + {data && data.class_results.length === 0 ? ( +
No projected trophy winners found.
+ ) : null} + + {data?.class_results?.length ? : null} +
+ ) +} + +function StatusBadge({ status }: { status?: string }) { + if (status === 'confirmed') return Confirmed + if (status === 'surprise') return Surprise + if (status === 'missed') return Missed + return None +} + +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(null) + const [error, setError] = useState(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 ( +
+ + + Projection + + + Winners + + + } + /> + + {error ?
{error}
: null} + + + +
+ + + + + + +
+ + {winnerGender ? ( +
+
Winner Gender Breakdown
+
+
+ + + +
+
+
+ ) : null} + + {data && data.class_results.length === 0 ? ( +
No data found for the selected school year.
+ ) : null} + + {data?.class_results?.map((section) => { + const rows = FinalStatusRows({ students: section.students }) + return ( +
+
+
{section.section_name}
+
+ Fall ≥ {formatScore(section.fall_threshold)} | Year ≥ {formatScore(section.year_threshold)} | Accuracy {section.accuracy}% +
+
+
+ + + + + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((student) => ( + + + + + + + + + + + )) + )} + +
NameGenderFallSpringYearPredictedActualStatus
+ No trophy changes in this class. +
{student.name}{formatScore(student.fall_score)}{formatScore(student.spring_score)}{formatScore(student.year_score)}{student.predicted ? 'Yes' : 'No'}{student.actual ? 'Yes' : 'No'}
+
+
+ ) + })} +
+ ) +}