From 8e79201a3c985c9b3537f1d7616a3ef6886a1d77 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 7 Jun 2026 00:52:04 -0400 Subject: [PATCH] add school year model --- src/App.tsx | 6 + src/api/schoolYears.ts | 294 +++++ src/components/CiPartials.tsx | 27 +- src/hooks/useSchoolYearOptions.ts | 88 ++ src/lib/schoolYearOptions.ts | 142 +++ src/pages/AdministratorToolPages.tsx | 45 +- .../SchoolYearsManagementPage.tsx | 1072 +++++++++++++++++ .../attendance/AdminsAttendanceFormPage.tsx | 18 +- src/pages/attendance/EarlyDismissalsPage.tsx | 18 +- .../ParentAttendanceReportsAdminPage.tsx | 18 +- .../attendance/TeacherAttendanceFormPage.tsx | 18 +- .../attendance/TeacherAttendanceMonthPage.tsx | 11 +- src/pages/attendance/ViolationsPages.tsx | 33 +- src/pages/classPrep/ClassPrepListPage.tsx | 29 +- src/pages/discounts/AcademicFilterBar.tsx | 54 +- .../EnrollmentWithdrawalPage.tsx | 16 +- src/pages/grading/ParticipationPage.tsx | 27 +- src/pages/inventory/InventorySummaryPage.tsx | 19 +- .../inventory/TeacherBookDistributePage.tsx | 10 +- .../invoicePayment/InvoiceManagementPage.tsx | 15 +- src/pages/payment/ExtraChargesPage.tsx | 22 +- src/pages/payment/FinancialReportPage.tsx | 24 +- .../payment/FinancialReportSummaryPage.tsx | 20 +- .../ReimbursementsIndexPage.tsx | 11 +- src/pages/report/CombinedReportPage.tsx | 20 +- src/pages/rfid/BadgeScanLogsPage.tsx | 9 +- .../scoreAnalysis/ScorePredictionPage.tsx | 19 +- src/pages/slips/SlipPreviewListPage.tsx | 18 +- .../student/StudentScoreCardListPage.tsx | 20 +- src/pages/teacher/TeacherCorePages.tsx | 23 +- src/pages/teacher/TeacherRestPages.tsx | 86 +- src/pages/trophy/TrophyPages.tsx | 12 +- 32 files changed, 2031 insertions(+), 213 deletions(-) create mode 100644 src/api/schoolYears.ts create mode 100644 src/hooks/useSchoolYearOptions.ts create mode 100644 src/lib/schoolYearOptions.ts create mode 100644 src/pages/administrator/SchoolYearsManagementPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 7b715f9..68d8294 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -77,6 +77,11 @@ const ViewRouteSitemapPage = lazy(() => const ViewRoutePlaceholderPage = lazy(() => import('./pages/ViewRoutePlaceholderPage').then((m) => ({ default: m.ViewRoutePlaceholderPage })), ) +const SchoolYearsManagementPage = lazy(() => + import('./pages/administrator/SchoolYearsManagementPage').then((m) => ({ + default: m.SchoolYearsManagementPage, + })), +) const NavBuilderPage = lazy(() => import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })), ) @@ -1126,6 +1131,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/api/schoolYears.ts b/src/api/schoolYears.ts new file mode 100644 index 0000000..5c6ce34 --- /dev/null +++ b/src/api/schoolYears.ts @@ -0,0 +1,294 @@ +import { apiFetch } from './http' + +export type SchoolYearStatus = 'draft' | 'active' | 'closed' | 'archived' | string + +export type SchoolYearRecord = { + id: number + name: string + start_date: string + end_date: string + status: SchoolYearStatus + is_current: boolean + closed_at?: string | null + closed_by?: number | null +} + +export type SchoolYearSummary = { + school_year: SchoolYearRecord + totals: { + students_considered: number + students_blocked: number + parents_with_balances: number + net_balance_to_transfer: number + } +} + +export type SchoolYearPromotionRow = { + student_id: number + student_name: string + parent_id?: number | null + current_grade?: string | null + current_class?: string | null + promotion_action: string + target_grade?: string | null + target_class?: string | null + target_class_section_id?: number | null + warnings?: string[] + decision?: string | null + score?: number | null +} + +export type SchoolYearPromotionPreview = { + rows: SchoolYearPromotionRow[] + summary: { + total_students: number + promote: number + repeat: number + graduate: number + transfer: number + withdraw: number + hold: number + } +} + +export type SchoolYearParentBalanceRow = { + parent_id: number + parent_name?: string | null + student_names?: string[] + old_unpaid_balance: number + credit_balance: number + amount_to_transfer: number + existing_transfer_conflict?: boolean +} + +export type SchoolYearParentBalancePreview = { + rows: SchoolYearParentBalanceRow[] + summary: { + parents_with_non_zero_balance: number + parents_with_credit_balance: number + total_old_unpaid_balance: number + total_credit_balance: number + net_balance_to_transfer: number + existing_transfer_conflicts: number + } +} + +export type SchoolYearCloseValidation = { + can_close: boolean + errors: string[] + warnings: string[] + promotion_summary: SchoolYearPromotionPreview['summary'] + financial_summary: SchoolYearParentBalancePreview['summary'] +} + +export type SchoolYearClosePreview = { + school_year: SchoolYearRecord + validation: SchoolYearCloseValidation + promotion_preview: SchoolYearPromotionPreview + parent_balances: SchoolYearParentBalancePreview +} + +export type SchoolYearCloseResult = { + closed_school_year: SchoolYearRecord + new_school_year: SchoolYearRecord + promotion_counts?: Record + balance_counts?: Record +} + +export type SaveSchoolYearPayload = { + name: string + start_date: string + end_date: string +} + +export type CloseSchoolYearPayload = { + new_school_year: SaveSchoolYearPayload + transfer_unpaid_balances?: boolean + confirmation?: string +} + +type ApiListResponse = { + ok?: boolean + data?: SchoolYearRecord[] +} + +type ApiRecordResponse = { + ok?: boolean + data?: SchoolYearRecord +} + +type ApiSummaryResponse = { + ok?: boolean + data?: SchoolYearSummary +} + +type ApiValidationResponse = { + ok?: boolean + validation?: SchoolYearCloseValidation +} + +type ApiPreviewResponse = { + ok?: boolean + data?: SchoolYearClosePreview +} + +type ApiParentBalancesResponse = { + ok?: boolean + data?: SchoolYearParentBalancePreview +} + +type ApiPromotionPreviewResponse = { + ok?: boolean + data?: SchoolYearPromotionPreview +} + +type ApiCloseResponse = { + ok?: boolean + data?: SchoolYearCloseResult +} + +export async function fetchSchoolYears(): Promise { + const response = await apiFetch('/api/v1/school-years') + return response.data ?? [] +} + +export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise { + const response = await apiFetch('/api/v1/school-years', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.data) { + throw new Error('School year creation returned no record.') + } + + return response.data +} + +export async function updateSchoolYear( + schoolYearId: number, + payload: SaveSchoolYearPayload, +): Promise { + const response = await apiFetch(`/api/v1/school-years/${schoolYearId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.data) { + throw new Error('School year update returned no record.') + } + + return response.data +} + +export async function fetchSchoolYearSummary(schoolYearId: number): Promise { + const response = await apiFetch(`/api/v1/school-years/${schoolYearId}/summary`) + if (!response.data) { + throw new Error('School year summary returned no data.') + } + return response.data +} + +export async function validateSchoolYearClose( + schoolYearId: number, + payload: CloseSchoolYearPayload, +): Promise { + const response = await apiFetch( + `/api/v1/school-years/${schoolYearId}/validate-close`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) + + if (!response.validation) { + throw new Error('School year validation returned no payload.') + } + + return response.validation +} + +export async function previewSchoolYearClose( + schoolYearId: number, + payload: CloseSchoolYearPayload, +): Promise { + const response = await apiFetch( + `/api/v1/school-years/${schoolYearId}/preview-close`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ) + + if (!response.data) { + throw new Error('School year close preview returned no payload.') + } + + return response.data +} + +export async function closeSchoolYear( + schoolYearId: number, + payload: CloseSchoolYearPayload, +): Promise { + const response = await apiFetch(`/api/v1/school-years/${schoolYearId}/close`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.data) { + throw new Error('School year close returned no payload.') + } + + return response.data +} + +export async function reopenSchoolYear(schoolYearId: number): Promise { + const response = await apiFetch(`/api/v1/school-years/${schoolYearId}/reopen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + if (!response.data) { + throw new Error('School year reopen returned no payload.') + } + + return response.data +} + +export async function fetchSchoolYearParentBalances( + schoolYearId: number, + toSchoolYear?: string, +): Promise { + const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : '' + const response = await apiFetch( + `/api/v1/school-years/${schoolYearId}/parent-balances${query}`, + ) + + if (!response.data) { + throw new Error('Parent balances preview returned no payload.') + } + + return response.data +} + +export async function fetchSchoolYearPromotionPreview( + schoolYearId: number, + toSchoolYear?: string, +): Promise { + const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : '' + const response = await apiFetch( + `/api/v1/school-years/${schoolYearId}/promotion-preview${query}`, + ) + + if (!response.data) { + throw new Error('Promotion preview returned no payload.') + } + + return response.data +} diff --git a/src/components/CiPartials.tsx b/src/components/CiPartials.tsx index a798e6e..9212bed 100644 --- a/src/components/CiPartials.tsx +++ b/src/components/CiPartials.tsx @@ -1,5 +1,7 @@ import { Link } from 'react-router-dom' import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react' +import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions' +import type { LegacySchoolYearValue } from '../lib/schoolYearOptions' export type CiFlashMessage = { id?: string @@ -61,19 +63,22 @@ export function CiAcademicFilter({ classSectionId, onApply, }: { - schoolYears?: Array + schoolYears?: LegacySchoolYearValue[] selectedYear?: string | null selectedSemester?: string | null classSectionId?: string | number | null onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void }) { - const years = (schoolYears ?? []).map((year) => (typeof year === 'string' ? year : year.school_year)) - const [schoolYear, setSchoolYear] = useState(selectedYear ?? years[0] ?? '') + const { options, selectedYear: defaultYear } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: selectedYear, + }) + const [schoolYear, setSchoolYear] = useState(selectedYear ?? defaultYear) const [semester, setSemester] = useState(selectedSemester ?? '') useEffect(() => { - setSchoolYear(selectedYear ?? years[0] ?? '') - }, [selectedYear, years.join('|')]) + setSchoolYear(selectedYear ?? defaultYear) + }, [defaultYear, selectedYear]) useEffect(() => { setSemester(selectedSemester ?? '') @@ -104,10 +109,10 @@ export function CiAcademicFilter({ value={schoolYear} onChange={(event) => setSchoolYear(event.target.value)} > - {years.length > 0 ? ( - years.map((year) => ( - )) ) : ( @@ -143,9 +148,9 @@ export function CiAcademicFilter({ type="button" className="btn btn-outline-secondary btn-sm" onClick={() => { - setSchoolYear(years[0] ?? '') + setSchoolYear(defaultYear) setSemester('') - onApply?.({ schoolYear: years[0] ?? '', semester: '', classSectionId: undefined }) + onApply?.({ schoolYear: defaultYear, semester: '', classSectionId: undefined }) }} > Reset diff --git a/src/hooks/useSchoolYearOptions.ts b/src/hooks/useSchoolYearOptions.ts new file mode 100644 index 0000000..1c462fa --- /dev/null +++ b/src/hooks/useSchoolYearOptions.ts @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useState } from 'react' +import { fetchSchoolYears, type SchoolYearRecord } from '../api/schoolYears' +import { + defaultSchoolYearOption, + mergeSchoolYearOptions, + type LegacySchoolYearValue, +} from '../lib/schoolYearOptions' + +let cachedSchoolYears: SchoolYearRecord[] | null = null +let cachedSchoolYearsPromise: Promise | null = null + +async function loadCanonicalSchoolYears(): Promise { + if (cachedSchoolYears) return cachedSchoolYears + + if (!cachedSchoolYearsPromise) { + cachedSchoolYearsPromise = fetchSchoolYears() + .then((years) => { + cachedSchoolYears = years + return years + }) + .finally(() => { + cachedSchoolYearsPromise = null + }) + } + + return cachedSchoolYearsPromise +} + +export function useSchoolYearOptions({ + legacyYears, + preferredYear, + enabled = true, +}: { + legacyYears?: LegacySchoolYearValue[] + preferredYear?: string | null + enabled?: boolean +}) { + const [canonicalYears, setCanonicalYears] = useState(cachedSchoolYears ?? []) + + useEffect(() => { + if (!enabled) return + + if (cachedSchoolYears) { + setCanonicalYears(cachedSchoolYears) + return + } + + let cancelled = false + loadCanonicalSchoolYears() + .then((years) => { + if (!cancelled) setCanonicalYears(years) + }) + .catch(() => { + if (!cancelled) setCanonicalYears([]) + }) + + return () => { + cancelled = true + } + }, [enabled]) + + const options = useMemo( + () => + mergeSchoolYearOptions({ + canonicalYears, + legacyYears, + extraYears: [preferredYear], + }), + [canonicalYears, legacyYears, preferredYear], + ) + + const selectedYear = useMemo( + () => defaultSchoolYearOption(options, preferredYear), + [options, preferredYear], + ) + + const currentYear = useMemo( + () => options.find((option) => option.isCurrent)?.value ?? selectedYear, + [options, selectedYear], + ) + + return { + canonicalYears, + currentYear, + options, + selectedYear, + } +} diff --git a/src/lib/schoolYearOptions.ts b/src/lib/schoolYearOptions.ts new file mode 100644 index 0000000..6880507 --- /dev/null +++ b/src/lib/schoolYearOptions.ts @@ -0,0 +1,142 @@ +import type { SchoolYearRecord } from '../api/schoolYears' + +export type LegacySchoolYearValue = + | string + | { + id?: number | null + name?: string | null + school_year?: string | null + status?: string | null + is_current?: boolean | null + } + +export type SchoolYearOption = { + id?: number + value: string + label: string + status?: string | null + isCurrent: boolean +} + +export function buildCalendarSchoolYear(date = new Date()): string { + const year = date.getFullYear() + return date.getMonth() >= 8 ? `${year}-${year + 1}` : `${year - 1}-${year}` +} + +export function normalizeSchoolYearValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function optionLabel(name: string, status?: string | null, isCurrent = false): string { + const normalizedStatus = String(status ?? '').trim().toLowerCase() + + if (isCurrent) { + return normalizedStatus && normalizedStatus !== 'active' + ? `${name} (Current, ${capitalize(normalizedStatus)})` + : `${name} (Current)` + } + + if (!normalizedStatus || normalizedStatus === 'active') { + return name + } + + return `${name} (${capitalize(normalizedStatus)})` +} + +function makeOption( + name: string, + status?: string | null, + isCurrent = false, + id?: number | null, +): SchoolYearOption | null { + const value = normalizeSchoolYearValue(name) + if (!value) return null + + return { + id: id == null ? undefined : Number(id), + value, + label: optionLabel(value, status, isCurrent), + status: status ?? undefined, + isCurrent, + } +} + +function optionFromRecord(record: SchoolYearRecord): SchoolYearOption | null { + return makeOption(record.name, record.status, Boolean(record.is_current), record.id) +} + +function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null { + if (typeof value === 'string') { + return makeOption(value) + } + + return makeOption( + value.name ?? value.school_year ?? '', + value.status ?? undefined, + Boolean(value.is_current), + value.id, + ) +} + +function compareSchoolYears(left: SchoolYearOption, right: SchoolYearOption): number { + if (left.isCurrent !== right.isCurrent) { + return Number(right.isCurrent) - Number(left.isCurrent) + } + + return right.value.localeCompare(left.value, undefined, { + numeric: true, + sensitivity: 'base', + }) +} + +export function mergeSchoolYearOptions(params: { + canonicalYears?: SchoolYearRecord[] + legacyYears?: LegacySchoolYearValue[] + extraYears?: Array +}): SchoolYearOption[] { + const map = new Map() + + for (const record of params.canonicalYears ?? []) { + const option = optionFromRecord(record) + if (option) map.set(option.value, option) + } + + for (const value of params.legacyYears ?? []) { + const option = optionFromLegacy(value) + if (!option || map.has(option.value)) continue + map.set(option.value, option) + } + + for (const extraYear of params.extraYears ?? []) { + const option = makeOption(String(extraYear ?? '')) + if (!option || map.has(option.value)) continue + map.set(option.value, option) + } + + return [...map.values()].sort(compareSchoolYears) +} + +export function defaultSchoolYearOption( + options: SchoolYearOption[], + preferredYear?: string | null, +): string { + const preferred = normalizeSchoolYearValue(preferredYear) + if (preferred && options.some((option) => option.value === preferred)) { + return preferred + } + + const current = options.find((option) => option.isCurrent) + if (current) return current.value + + return options[0]?.value ?? buildCalendarSchoolYear() +} + +export function canonicalSchoolYearName(records: SchoolYearRecord[]): string | null { + if (records.length === 0) return null + const current = records.find((record) => record.is_current) + return current?.name ?? records[0]?.name ?? null +} diff --git a/src/pages/AdministratorToolPages.tsx b/src/pages/AdministratorToolPages.tsx index 197c11a..cafd13e 100644 --- a/src/pages/AdministratorToolPages.tsx +++ b/src/pages/AdministratorToolPages.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 're import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import './AdminDailyAttendance.css' import { ApiHttpError } from '../api/http' +import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions' import { assignTeacherClass, assignStudentClass, @@ -1050,12 +1051,6 @@ export function AdminCalendarEditPage() { /** School calendar admin — `GET /api/v1/settings/school-calendar/events` (JWT). SPA: `/app/administrator/events` or `calendar_view`. */ -function currentSchoolYear(): string { - const now = new Date() - const y = now.getFullYear() - return now.getMonth() >= 8 ? `${y}-${y + 1}` : `${y - 1}-${y}` -} - export function AdminCalendarViewPage() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() @@ -1063,12 +1058,15 @@ export function AdminCalendarViewPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const schoolYear = searchParams.get('school_year') ?? '' + const { options, currentYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) useEffect(() => { - if (!schoolYear) { - setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }), { replace: true }) + if (!schoolYear && currentYear) { + setSearchParams(new URLSearchParams({ school_year: currentYear }), { replace: true }) } - }, []) + }, [currentYear, schoolYear, setSearchParams]) async function load() { setLoading(true) @@ -1124,14 +1122,27 @@ export function AdminCalendarViewPage() { >
- +
@@ -1307,6 +1318,10 @@ export function AdminClassAssignmentPage() { key: 'school_id', direction: 'asc', }) + const { options, selectedYear: defaultYear } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: selectedYear, + }) async function load(year?: string | null) { setLoading(true) @@ -1316,7 +1331,7 @@ export function AdminClassAssignmentPage() { const classSections = data.classSections ?? [] setSections(classSections) setSchoolYears(data.schoolYears ?? []) - setSelectedYear(data.selectedYear ?? data.schoolYear ?? '') + setSelectedYear(data.selectedYear ?? data.schoolYear ?? defaultYear) setSelectedSemester(data.selectedSemester ?? data.semester ?? '') setExpandedSections(new Set(classSections.length > 0 ? ['0'] : [])) setError(null) @@ -1397,11 +1412,11 @@ export function AdminClassAssignmentPage() {
diff --git a/src/pages/administrator/SchoolYearsManagementPage.tsx b/src/pages/administrator/SchoolYearsManagementPage.tsx new file mode 100644 index 0000000..aa39e32 --- /dev/null +++ b/src/pages/administrator/SchoolYearsManagementPage.tsx @@ -0,0 +1,1072 @@ +import { useEffect, useMemo, useState, type FormEvent } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { CiFlashMessages, type CiFlashMessage } from '../../components/CiPartials' +import { ApiHttpError } from '../../api/http' +import { + closeSchoolYear, + createSchoolYear, + fetchSchoolYearParentBalances, + fetchSchoolYearPromotionPreview, + fetchSchoolYearSummary, + fetchSchoolYears, + previewSchoolYearClose, + reopenSchoolYear, + updateSchoolYear, + validateSchoolYearClose, + type CloseSchoolYearPayload, + type SchoolYearClosePreview, + type SchoolYearCloseValidation, + type SchoolYearParentBalancePreview, + type SchoolYearPromotionPreview, + type SchoolYearRecord, + type SchoolYearSummary, +} from '../../api/schoolYears' + +type YearFormState = { + name: string + start_date: string + end_date: string +} + +type CloseFormState = { + name: string + start_date: string + end_date: string + confirmation: string + transfer_unpaid_balances: boolean +} + +function emptyYearForm(): YearFormState { + return { name: '', start_date: '', end_date: '' } +} + +function emptyCloseForm(): CloseFormState { + return { + name: '', + start_date: '', + end_date: '', + confirmation: '', + transfer_unpaid_balances: true, + } +} + +function normalizeApiError(error: unknown, fallback: string): string { + if (error instanceof ApiHttpError) return error.message || fallback + if (error instanceof Error) return error.message + return fallback +} + +function toDisplayDate(value: string | null | undefined): string { + if (!value) return '—' + const iso = value.length > 10 ? value.slice(0, 10) : value + const parsed = new Date(`${iso}T00:00:00`) + if (Number.isNaN(parsed.getTime())) return value + return parsed.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +function formatMoney(value: number | null | undefined): string { + return Number(value ?? 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} + +function statusBadge(status: string): string { + switch (status) { + case 'active': + return 'success' + case 'closed': + return 'danger' + case 'draft': + return 'warning' + default: + return 'secondary' + } +} + +function suggestNextName(name: string): string { + const fullRange = name.match(/^(\d{4})\s*-\s*(\d{4})$/) + if (fullRange) { + return `${Number(fullRange[1]) + 1}-${Number(fullRange[2]) + 1}` + } + + const shortRange = name.match(/^(\d{4})\s*-\s*(\d{2})$/) + if (shortRange) { + const nextStart = Number(shortRange[1]) + 1 + const nextEnd = (Number(shortRange[2]) + 1).toString().padStart(2, '0') + return `${nextStart}-${nextEnd}` + } + + return '' +} + +function shiftSchoolYearDate(value: string): string { + const parts = value.split('-') + if (parts.length !== 3) return '' + return `${Number(parts[0]) + 1}-${parts[1]}-${parts[2]}` +} + +function defaultDraftFor(activeYear: SchoolYearRecord | null): YearFormState { + if (!activeYear) return emptyYearForm() + return { + name: suggestNextName(activeYear.name), + start_date: shiftSchoolYearDate(activeYear.start_date), + end_date: shiftSchoolYearDate(activeYear.end_date), + } +} + +function closePayloadFrom(state: CloseFormState): CloseSchoolYearPayload { + return { + new_school_year: { + name: state.name.trim(), + start_date: state.start_date, + end_date: state.end_date, + }, + transfer_unpaid_balances: state.transfer_unpaid_balances, + confirmation: state.confirmation.trim(), + } +} + +export function SchoolYearsManagementPage() { + const [searchParams, setSearchParams] = useSearchParams() + const [years, setYears] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingMessage, setLoadingMessage] = useState('Loading school years…') + const [error, setError] = useState(null) + const [messages, setMessages] = useState([]) + const [summary, setSummary] = useState(null) + const [promotionPreview, setPromotionPreview] = useState(null) + const [parentBalances, setParentBalances] = useState(null) + const [closeValidation, setCloseValidation] = useState(null) + const [closePreview, setClosePreview] = useState(null) + const [createForm, setCreateForm] = useState(emptyYearForm()) + const [editForm, setEditForm] = useState(emptyYearForm()) + const [closeForm, setCloseForm] = useState(emptyCloseForm()) + const [busyCreate, setBusyCreate] = useState(false) + const [busyEdit, setBusyEdit] = useState(false) + const [busyOpenId, setBusyOpenId] = useState(null) + const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | null>(null) + const selectedYearId = Number(searchParams.get('year') ?? 0) || null + + const activeYear = useMemo( + () => years.find((year) => Boolean(year.is_current)) ?? null, + [years], + ) + + const selectedYear = useMemo(() => { + if (years.length === 0) return null + if (selectedYearId != null) { + return years.find((year) => year.id === selectedYearId) ?? null + } + return activeYear ?? years[0] ?? null + }, [activeYear, selectedYearId, years]) + + async function refreshYears(preferredYearId?: number | null) { + setLoading(true) + setLoadingMessage('Loading school years…') + setError(null) + + try { + const list = await fetchSchoolYears() + setYears(list) + + const nextSelectedId = + preferredYearId ?? + (selectedYearId != null && list.some((year) => year.id === selectedYearId) + ? selectedYearId + : list.find((year) => year.is_current)?.id ?? list[0]?.id ?? null) + + if (nextSelectedId != null) { + setSearchParams((current) => { + const next = new URLSearchParams(current) + next.set('year', String(nextSelectedId)) + return next + }, { replace: true }) + } + + const nextActive = list.find((year) => year.is_current) ?? null + const draftDefaults = defaultDraftFor(nextActive) + setCreateForm((current) => + current.name || current.start_date || current.end_date ? current : draftDefaults, + ) + + if (nextActive) { + setCloseForm((current) => + current.name || current.start_date || current.end_date + ? current + : { + ...current, + ...draftDefaults, + }, + ) + } else { + setCloseForm(emptyCloseForm()) + } + } catch (loadError) { + setYears([]) + setSummary(null) + setPromotionPreview(null) + setParentBalances(null) + setCloseValidation(null) + setClosePreview(null) + setError(normalizeApiError(loadError, 'Unable to load school years.')) + } finally { + setLoading(false) + } + } + + useEffect(() => { + void refreshYears() + // search params are intentionally handled by explicit selection logic + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!selectedYear) { + setSummary(null) + setEditForm(emptyYearForm()) + setPromotionPreview(null) + setParentBalances(null) + return + } + + setEditForm({ + name: selectedYear.name, + start_date: selectedYear.start_date, + end_date: selectedYear.end_date, + }) + + let cancelled = false + setSummary(null) + setLoadingMessage(`Loading ${selectedYear.name} summary…`) + + ;(async () => { + try { + const nextSummary = await fetchSchoolYearSummary(selectedYear.id) + if (!cancelled) { + setSummary(nextSummary) + } + } catch (loadError) { + if (!cancelled) { + setError(normalizeApiError(loadError, 'Unable to load school year summary.')) + } + } + })() + + return () => { + cancelled = true + } + }, [selectedYear]) + + useEffect(() => { + const defaults = defaultDraftFor(activeYear) + if (!activeYear) { + setCloseForm(emptyCloseForm()) + return + } + + setCloseForm((current) => ({ + ...current, + name: current.name || defaults.name, + start_date: current.start_date || defaults.start_date, + end_date: current.end_date || defaults.end_date, + })) + }, [activeYear]) + + function pushMessage(type: CiFlashMessage['type'], message: string) { + setMessages([{ type, message }]) + } + + function selectYear(yearId: number) { + setSearchParams((current) => { + const next = new URLSearchParams(current) + next.set('year', String(yearId)) + return next + }) + setPromotionPreview(null) + setParentBalances(null) + } + + async function handleCreate(event: FormEvent) { + event.preventDefault() + setBusyCreate(true) + setError(null) + try { + const created = await createSchoolYear(createForm) + pushMessage('success', `Draft school year ${created.name} created.`) + setCreateForm(emptyYearForm()) + await refreshYears(created.id) + } catch (saveError) { + setError(normalizeApiError(saveError, 'Unable to create school year.')) + } finally { + setBusyCreate(false) + } + } + + async function handleEdit(event: FormEvent) { + event.preventDefault() + if (!selectedYear) return + setBusyEdit(true) + setError(null) + try { + const updated = await updateSchoolYear(selectedYear.id, editForm) + pushMessage('success', `School year ${updated.name} updated.`) + await refreshYears(updated.id) + } catch (saveError) { + setError(normalizeApiError(saveError, 'Unable to update school year.')) + } finally { + setBusyEdit(false) + } + } + + async function handleReopen(year: SchoolYearRecord) { + setBusyOpenId(year.id) + setError(null) + try { + const reopened = await reopenSchoolYear(year.id) + pushMessage('success', `School year ${reopened.name} is now active.`) + setCloseValidation(null) + setClosePreview(null) + await refreshYears(reopened.id) + } catch (actionError) { + setError(normalizeApiError(actionError, 'Unable to open school year.')) + } finally { + setBusyOpenId(null) + } + } + + async function loadPromotionPreview() { + if (!selectedYear) return + setLoadingMessage(`Loading ${selectedYear.name} promotion preview…`) + setError(null) + try { + const preview = await fetchSchoolYearPromotionPreview( + selectedYear.id, + suggestNextName(selectedYear.name), + ) + setPromotionPreview(preview) + } catch (loadError) { + setError(normalizeApiError(loadError, 'Unable to load promotion preview.')) + } + } + + async function loadParentBalances() { + if (!selectedYear) return + setLoadingMessage(`Loading ${selectedYear.name} parent balances…`) + setError(null) + try { + const balances = await fetchSchoolYearParentBalances( + selectedYear.id, + suggestNextName(selectedYear.name), + ) + setParentBalances(balances) + } catch (loadError) { + setError(normalizeApiError(loadError, 'Unable to load parent balance preview.')) + } + } + + async function handleValidateClose() { + if (!activeYear) return + setBusyCloseAction('validate') + setError(null) + try { + const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm)) + setCloseValidation(validation) + setClosePreview(null) + } catch (actionError) { + setError(normalizeApiError(actionError, 'Unable to validate school year closure.')) + } finally { + setBusyCloseAction(null) + } + } + + async function handlePreviewClose() { + if (!activeYear) return + setBusyCloseAction('preview') + setError(null) + try { + const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm)) + setCloseValidation(preview.validation) + setClosePreview(preview) + } catch (actionError) { + setError(normalizeApiError(actionError, 'Unable to preview school year closure.')) + } finally { + setBusyCloseAction(null) + } + } + + async function handleClose(event: FormEvent) { + event.preventDefault() + if (!activeYear) return + setBusyCloseAction('close') + setError(null) + try { + const result = await closeSchoolYear(activeYear.id, closePayloadFrom(closeForm)) + pushMessage( + 'success', + `Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`, + ) + setCloseValidation(null) + setClosePreview(null) + setPromotionPreview(null) + setParentBalances(null) + setCloseForm(emptyCloseForm()) + await refreshYears(result.new_school_year.id) + } catch (actionError) { + setError(normalizeApiError(actionError, 'Unable to close school year.')) + } finally { + setBusyCloseAction(null) + } + } + + const summaryCards = summary?.totals + const promotionRows = promotionPreview?.rows ?? [] + const balanceRows = parentBalances?.rows ?? [] + const closePromotionRows = closePreview?.promotion_preview.rows ?? [] + const closeBalanceRows = closePreview?.parent_balances.rows ?? [] + + return ( +
+ + +
+
+

School Years Management

+

+ Control draft years, metadata updates, reopening, validation, promotion preview, + parent balance carryover, and year closure through the matching Laravel API routes. +

+
+
+ Uses /api/v1/school-years with JWT Authorization: Bearer. +
+
+ + + {error ?
{error}
: null} + +
+
+
+
+

School Years

+ {loading && years.length === 0 ? ( +

{loadingMessage}

+ ) : years.length === 0 ? ( +

No school years found.

+ ) : ( +
+ {years.map((year) => ( +
+
+
+ +
+ {toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)} +
+
+ + {year.status.toUpperCase()} + +
+
+ + {!year.is_current ? ( + + ) : ( + Current + )} +
+
+ ))} +
+ )} +
+
+ +
+
+
+

Create Draft Year

+ +
+
void handleCreate(event)}> +
+ + + setCreateForm((current) => ({ ...current, name: event.target.value })) + } + required + /> +
+
+
+ + + setCreateForm((current) => ({ ...current, start_date: event.target.value })) + } + required + /> +
+
+ + + setCreateForm((current) => ({ ...current, end_date: event.target.value })) + } + required + /> +
+
+ +
+
+
+
+ +
+
+
+
+
+

{selectedYear?.name ?? 'Year summary'}

+
+ Review the selected year before updating metadata, reopening, or closing. +
+
+ {selectedYear ? ( + + {selectedYear.status.toUpperCase()} + + ) : null} +
+ + {!selectedYear ? ( +

Select a school year to inspect it.

+ ) : !summaryCards ? ( +

Loading summary…

+ ) : ( +
+
+
+
Students considered
+
{summaryCards.students_considered}
+
+
+
+
+
Students blocked
+
{summaryCards.students_blocked}
+
+
+
+
+
Parents with balances
+
{summaryCards.parents_with_balances}
+
+
+
+
+
Net balance impact
+
${formatMoney(summaryCards.net_balance_to_transfer)}
+
+
+
+ )} +
+
+ +
+
+

Edit Selected Year

+ {!selectedYear ? ( +

Select a school year to edit its metadata.

+ ) : ( +
void handleEdit(event)}> +
+ + + setEditForm((current) => ({ ...current, name: event.target.value })) + } + required + /> +
+
+
+ + + setEditForm((current) => ({ ...current, start_date: event.target.value })) + } + required + /> +
+
+ + + setEditForm((current) => ({ ...current, end_date: event.target.value })) + } + required + /> +
+
+
+ Safe renames depend on whether finance and enrollment records already reference + the existing year label. +
+ +
+ )} +
+
+ +
+
+
+
+

Promotion And Balance Inspection

+
+ Read-only previews for the selected year using the dedicated promotion and + parent-balance endpoints. +
+
+
+ + +
+
+ +
+
+
+

Promotion preview

+ {!promotionPreview ? ( +

Not loaded yet.

+ ) : ( + <> +
+ {promotionPreview.summary.total_students} students scanned,{' '} + {promotionPreview.summary.hold} hold rows. +
+
+ + + + + + + + + + + {promotionRows.length === 0 ? ( + + + + ) : ( + promotionRows.slice(0, 8).map((row) => ( + + + + + + + )) + )} + +
StudentCurrentActionTarget
+ No rows found. +
+
{row.student_name}
+ {row.warnings?.length ? ( +
+ {row.warnings.join('; ')} +
+ ) : null} +
{row.current_grade ?? '—'}{row.promotion_action}{row.target_grade ?? '—'}
+
+ + )} +
+
+ +
+
+

Parent balance preview

+ {!parentBalances ? ( +

Not loaded yet.

+ ) : ( + <> +
+ {parentBalances.summary.parents_with_non_zero_balance} parents with + balances, net ${formatMoney(parentBalances.summary.net_balance_to_transfer)}. +
+
+ + + + + + + + + + {balanceRows.length === 0 ? ( + + + + ) : ( + balanceRows.slice(0, 8).map((row) => ( + + + + + + )) + )} + +
ParentStudentsTransfer
+ No rows found. +
{row.parent_name || `Parent #${row.parent_id}`}{row.student_names?.join(', ') || '—'}${formatMoney(row.amount_to_transfer)}
+
+ + )} +
+
+
+
+
+ +
+
+
+
+

Close Active Year

+
+ Validate, preview, and close the current year through the transactional + school-year closure endpoints. +
+
+ {activeYear ? ( + {activeYear.name} + ) : null} +
+ + {!activeYear ? ( +

No active school year is available to close.

+ ) : ( +
void handleClose(event)}> +
+
+ + + setCloseForm((current) => ({ ...current, name: event.target.value })) + } + required + /> +
+
+ + + setCloseForm((current) => ({ ...current, start_date: event.target.value })) + } + required + /> +
+
+ + + setCloseForm((current) => ({ ...current, end_date: event.target.value })) + } + required + /> +
+
+ +
+
+ + + setCloseForm((current) => ({ + ...current, + confirmation: event.target.value, + })) + } + placeholder={`CLOSE ${activeYear.name}`} + required + /> +
+
+
+ + setCloseForm((current) => ({ + ...current, + transfer_unpaid_balances: event.target.checked, + })) + } + /> + +
+
+
+ +
+ + + +
+
+ )} + + {closeValidation ? ( +
+
+ {closeValidation.can_close ? 'Closure validation passed.' : 'Closure is currently blocked.'} +
+
+
+ Students scanned: {closeValidation.promotion_summary.total_students} +
+
+ Missing decisions: {closeValidation.promotion_summary.hold} +
+
+ Parents with balances: {closeValidation.financial_summary.parents_with_non_zero_balance} +
+
+ Net transfer: ${formatMoney(closeValidation.financial_summary.net_balance_to_transfer)} +
+
+ {closeValidation.errors.length > 0 ? ( +
    + {closeValidation.errors.map((message) => ( +
  • {message}
  • + ))} +
+ ) : null} + {closeValidation.warnings.length > 0 ? ( +
    + {closeValidation.warnings.map((message) => ( +
  • {message}
  • + ))} +
+ ) : null} +
+ ) : null} + + {closePreview ? ( +
+
+
+
+

Closure promotion preview

+
+ + + + + + + + + + {closePromotionRows.slice(0, 8).map((row) => ( + + + + + + ))} + +
StudentActionTarget
{row.student_name}{row.promotion_action}{row.target_grade ?? '—'}
+
+
+
+
+
+

Closure balance preview

+
+ + + + + + + + + + {closeBalanceRows.slice(0, 8).map((row) => ( + + + + + + ))} + +
ParentStudentsTransfer
{row.parent_name || `Parent #${row.parent_id}`}{row.student_names?.join(', ') || '—'}${formatMoney(row.amount_to_transfer)}
+
+
+
+
+
+ ) : null} +
+
+
+
+
+ ) +} diff --git a/src/pages/attendance/AdminsAttendanceFormPage.tsx b/src/pages/attendance/AdminsAttendanceFormPage.tsx index cf44e42..6c1d033 100644 --- a/src/pages/attendance/AdminsAttendanceFormPage.tsx +++ b/src/pages/attendance/AdminsAttendanceFormPage.tsx @@ -1,5 +1,6 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchAdministratorAdminAttendance, saveAdministratorAdminAttendance, @@ -26,6 +27,9 @@ export function AdminsAttendanceFormPage() { const [saving, setSaving] = useState(false) const [message, setMessage] = useState(null) const [error, setError] = useState(null) + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) useEffect(() => { let cancelled = false @@ -143,13 +147,13 @@ export function AdminsAttendanceFormPage() { >
- +
diff --git a/src/pages/attendance/EarlyDismissalsPage.tsx b/src/pages/attendance/EarlyDismissalsPage.tsx index 7bcbd39..caa483a 100644 --- a/src/pages/attendance/EarlyDismissalsPage.tsx +++ b/src/pages/attendance/EarlyDismissalsPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useEffect, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { fetchEarlyDismissals, uploadEarlyDismissalSignature } from '../../api/session' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { apiUrl } from '../../lib/apiOrigin' import { EARLY_DISMISSALS_NEW_PATH, EARLY_DISMISSALS_PATH } from './attendancePaths' @@ -20,6 +21,9 @@ export function EarlyDismissalsPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [flash, setFlash] = useState(null) + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) useEffect(() => { let cancelled = false @@ -90,13 +94,13 @@ export function EarlyDismissalsPage() { >
- +
diff --git a/src/pages/attendance/ParentAttendanceReportsAdminPage.tsx b/src/pages/attendance/ParentAttendanceReportsAdminPage.tsx index d66c329..d2fd472 100644 --- a/src/pages/attendance/ParentAttendanceReportsAdminPage.tsx +++ b/src/pages/attendance/ParentAttendanceReportsAdminPage.tsx @@ -1,5 +1,6 @@ import { type FormEvent, useEffect, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchParentAttendanceReportsAdmin } from '../../api/session' import type { ParentAttendanceAdminReportRow } from '../../api/types' @@ -14,6 +15,9 @@ export function ParentAttendanceReportsAdminPage() { const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) useEffect(() => { let cancelled = false @@ -63,13 +67,13 @@ export function ParentAttendanceReportsAdminPage() {
- +
diff --git a/src/pages/attendance/TeacherAttendanceFormPage.tsx b/src/pages/attendance/TeacherAttendanceFormPage.tsx index cd486b5..8252cf0 100644 --- a/src/pages/attendance/TeacherAttendanceFormPage.tsx +++ b/src/pages/attendance/TeacherAttendanceFormPage.tsx @@ -1,5 +1,6 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session' import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths' import type { TeacherStaffAttendanceRow } from '../../api/types' @@ -26,6 +27,9 @@ export function TeacherAttendanceFormPage() { const [saving, setSaving] = useState(false) const [message, setMessage] = useState(null) const [error, setError] = useState(null) + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) useEffect(() => { let cancelled = false @@ -136,13 +140,13 @@ export function TeacherAttendanceFormPage() {
- +
diff --git a/src/pages/attendance/TeacherAttendanceMonthPage.tsx b/src/pages/attendance/TeacherAttendanceMonthPage.tsx index a64cc24..7eb3ed9 100644 --- a/src/pages/attendance/TeacherAttendanceMonthPage.tsx +++ b/src/pages/attendance/TeacherAttendanceMonthPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { apiUrl } from '../../lib/apiOrigin' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchStaffMonthlyAttendanceOverview, saveStaffMonthlyAttendanceCell, @@ -104,6 +105,10 @@ export function TeacherAttendanceMonthPage() { if (ys.length === 0 && schoolYearParam) ys.push(schoolYearParam) return ys }, [payload?.schoolYears, schoolYearParam]) + const { options } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: schoolYearParam, + }) const hasTeacherSections = (payload?.sections?.length ?? 0) > 0 const hasAdmins = (payload?.admins?.length ?? 0) > 0 @@ -202,9 +207,9 @@ export function TeacherAttendanceMonthPage() {
diff --git a/src/pages/attendance/ViolationsPages.tsx b/src/pages/attendance/ViolationsPages.tsx index 7fe50e3..783d27e 100644 --- a/src/pages/attendance/ViolationsPages.tsx +++ b/src/pages/attendance/ViolationsPages.tsx @@ -1,5 +1,6 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchAttendanceViolationsNotified, fetchAttendanceViolationsPending, @@ -29,6 +30,9 @@ export function ViolationsPendingPage() { const [searchParams, setSearchParams] = useSearchParams() const schoolYear = searchParams.get('school_year') ?? '' const semester = searchParams.get('semester') ?? '' + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) const filterQuery = useMemo(() => { const s = searchParams.toString() @@ -94,7 +98,18 @@ export function ViolationsPendingPage() {
- +
@@ -315,6 +330,9 @@ export function ViolationsNotifiedPage() { const [searchParams, setSearchParams] = useSearchParams() const schoolYear = searchParams.get('school_year') ?? '' const semester = searchParams.get('semester') ?? '' + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) const filterQuery = useMemo(() => { const s = searchParams.toString() @@ -399,7 +417,18 @@ export function ViolationsNotifiedPage() {
- +
diff --git a/src/pages/classPrep/ClassPrepListPage.tsx b/src/pages/classPrep/ClassPrepListPage.tsx index 5be1090..2bf73e0 100644 --- a/src/pages/classPrep/ClassPrepListPage.tsx +++ b/src/pages/classPrep/ClassPrepListPage.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session' import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types' @@ -9,14 +10,9 @@ function adjustQty(input: HTMLInputElement, delta: number) { input.value = String(curr + delta) } -function defaultSchoolYear() { - const y = new Date().getFullYear() - return `${y}-${y + 1}` -} - export function ClassPrepListPage() { const [searchParams, setSearchParams] = useSearchParams() - const schoolYear = searchParams.get('school_year') ?? defaultSchoolYear() + const schoolYear = searchParams.get('school_year') ?? '' const semester = searchParams.get('semester') ?? '' const [data, setData] = useState(null) @@ -24,19 +20,16 @@ export function ClassPrepListPage() { const [error, setError] = useState(null) const [savingId, setSavingId] = useState(null) const [saveMsg, setSaveMsg] = useState(null) - - const yearOptions = useMemo(() => { - const out: string[] = [] - for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`) - return out - }, []) + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) useEffect(() => { let cancelled = false setLoading(true) setError(null) fetchClassPrepIndex({ - school_year: schoolYear, + school_year: schoolYear || selectedYear, semester: semester || undefined, }) .then((d) => { @@ -115,11 +108,11 @@ export function ClassPrepListPage() { name="school_year" id="school_year" className="form-select form-select-sm w-auto" - defaultValue={schoolYear} + defaultValue={schoolYear || selectedYear} > - {yearOptions.map((y) => ( - ))} diff --git a/src/pages/discounts/AcademicFilterBar.tsx b/src/pages/discounts/AcademicFilterBar.tsx index c50f41e..5506ca6 100644 --- a/src/pages/discounts/AcademicFilterBar.tsx +++ b/src/pages/discounts/AcademicFilterBar.tsx @@ -1,10 +1,6 @@ -import { type FormEvent } from 'react' +import { type FormEvent, useEffect, useState } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' - -function defaultSchoolYear() { - const y = new Date().getFullYear() - return `${y}-${y + 1}` -} +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) { const [searchParams] = useSearchParams() @@ -12,24 +8,26 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) { const { pathname } = useLocation() const schoolYear = searchParams.get('school_year') ?? '' const semester = searchParams.get('semester') ?? '' + const { options, selectedYear } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: schoolYear, + }) + const [yearValue, setYearValue] = useState(schoolYear || selectedYear) + const [semesterValue, setSemesterValue] = useState(semester) - const years = - schoolYears && schoolYears.length > 0 - ? schoolYears - : (() => { - const out: string[] = [] - for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`) - return out - })() + useEffect(() => { + setYearValue(schoolYear || selectedYear) + }, [schoolYear, selectedYear]) + + useEffect(() => { + setSemesterValue(semester) + }, [semester]) function onSubmit(e: FormEvent) { e.preventDefault() - const fd = new FormData(e.currentTarget) const next = new URLSearchParams() - const sy = String(fd.get('school_year') ?? '') - const sem = String(fd.get('semester') ?? '') - if (sy) next.set('school_year', sy) - if (sem) next.set('semester', sem) + if (yearValue) next.set('school_year', yearValue) + if (semesterValue) next.set('semester', semesterValue) navigate({ pathname, search: next.toString() ? `?${next}` : '' }) } @@ -46,11 +44,12 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) { name="school_year" className="form-select form-select-sm" style={{ minWidth: 180 }} - defaultValue={schoolYear || years[0] || defaultSchoolYear()} + value={yearValue} + onChange={(event) => setYearValue(event.target.value)} > - {years.map((y) => ( - ))} @@ -63,7 +62,8 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) { name="semester" className="form-select form-select-sm" style={{ minWidth: 140 }} - defaultValue={semester} + value={semesterValue} + onChange={(event) => setSemesterValue(event.target.value)} > @@ -77,7 +77,11 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) { diff --git a/src/pages/enrollWithdraw/EnrollmentWithdrawalPage.tsx b/src/pages/enrollWithdraw/EnrollmentWithdrawalPage.tsx index 605facf..752f8c6 100644 --- a/src/pages/enrollWithdraw/EnrollmentWithdrawalPage.tsx +++ b/src/pages/enrollWithdraw/EnrollmentWithdrawalPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchEnrollmentWithdrawalPage, postEnrollmentWithdrawalAssignClass, @@ -88,6 +89,10 @@ export function EnrollmentWithdrawalPage() { /** Pending edits: undefined means “use row value”. */ const [pendingStatus, setPendingStatus] = useState>({}) const [pendingClass, setPendingClass] = useState>({}) + const { options, selectedYear: defaultYear } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: selectedYear || schoolYearParam, + }) const readOnly = missingYear || !isCurrentYear @@ -282,8 +287,7 @@ export function EnrollmentWithdrawalPage() { setBulkWorking(false) } - const yearSelectValue = - schoolYearParam || selectedYear || (schoolYears.length > 0 ? schoolYears[0] : '') + const yearSelectValue = schoolYearParam || selectedYear || defaultYear const semesterSelectValue = semesterParam return ( @@ -326,10 +330,10 @@ export function EnrollmentWithdrawalPage() { defaultValue={yearSelectValue} key={`${yearSelectValue}-${semesterSelectValue}`} > - {schoolYears.length > 0 ? ( - schoolYears.map((y) => ( - )) ) : ( diff --git a/src/pages/grading/ParticipationPage.tsx b/src/pages/grading/ParticipationPage.tsx index 1f4dbab..0988fe2 100644 --- a/src/pages/grading/ParticipationPage.tsx +++ b/src/pages/grading/ParticipationPage.tsx @@ -2,6 +2,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' import { fetchParticipation, postParticipation } from '../../api/grading' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { GRADING_PATH } from './gradingPaths' /** CI `grading/participation.php` */ @@ -11,6 +12,10 @@ function normalizeParticipationScore(value: unknown): number | string | null { export function ParticipationPage() { const [searchParams] = useSearchParams() + const requestedSchoolYear = searchParams.get('school_year') ?? '' + const { selectedYear } = useSchoolYearOptions({ + preferredYear: requestedSchoolYear, + }) const [rows, setRows] = useState< Array<{ student_id: number @@ -24,18 +29,24 @@ export function ParticipationPage() { const [title] = useState('Participation Scores') const [sectionName, setSectionName] = useState('') const [semester, setSemester] = useState(searchParams.get('semester') ?? '') - const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '') + const [schoolYear, setSchoolYear] = useState(requestedSchoolYear || selectedYear) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [tableSearch, setTableSearch] = useState('') const classSectionId = searchParams.get('class_section_id') ?? '' + const effectiveSchoolYear = schoolYear || selectedYear + const participationSearchParams = useMemo(() => { + const next = new URLSearchParams(searchParams) + if (effectiveSchoolYear) next.set('school_year', effectiveSchoolYear) + return next + }, [effectiveSchoolYear, searchParams]) useEffect(() => { let c = false setLoading(true) - fetchParticipation(searchParams) + fetchParticipation(participationSearchParams) .then((p) => { if (c) return if (p && typeof p === 'object') { @@ -57,7 +68,11 @@ export function ParticipationPage() { setLocked(Boolean(o.scores_locked ?? o.scoresLocked ?? o.locked)) setSectionName(typeof o.class_section_name === 'string' ? o.class_section_name : '') setSemester(typeof o.semester === 'string' ? o.semester : (searchParams.get('semester') ?? '')) - setSchoolYear(typeof o.school_year === 'string' ? o.school_year : (searchParams.get('school_year') ?? '')) + setSchoolYear( + typeof o.school_year === 'string' && o.school_year.trim() !== '' + ? o.school_year + : effectiveSchoolYear, + ) } setError(null) }) @@ -70,7 +85,7 @@ export function ParticipationPage() { return () => { c = true } - }, [searchParams]) + }, [effectiveSchoolYear, participationSearchParams, searchParams]) const visibleRows = useMemo(() => { const normalized = tableSearch.toLowerCase().trim() @@ -95,7 +110,7 @@ export function ParticipationPage() { try { const body: Record = { class_section_id: classSectionId ? Number(classSectionId) : undefined, - school_year: schoolYear || undefined, + school_year: effectiveSchoolYear || undefined, semester: semester || undefined, scores: rows.reduce>((carry, row) => { carry[String(row.student_id)] = { score: row.score ?? '' } @@ -103,7 +118,7 @@ export function ParticipationPage() { }, {}), } await postParticipation(body) - const next = await fetchParticipation(searchParams) + const next = await fetchParticipation(participationSearchParams) if (next && typeof next === 'object') { const o = next as Record const students = Array.isArray(o.students) ? o.students : [] diff --git a/src/pages/inventory/InventorySummaryPage.tsx b/src/pages/inventory/InventorySummaryPage.tsx index d44f08e..e9bb818 100644 --- a/src/pages/inventory/InventorySummaryPage.tsx +++ b/src/pages/inventory/InventorySummaryPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchInventorySummary } from '../../api/inventory' import { AcademicFilterBar } from '../discounts/AcademicFilterBar' import { INVENTORY_BOOK_BASE } from './inventoryPaths' @@ -20,6 +21,10 @@ export function InventorySummaryPage() { const [schoolYears, setSchoolYears] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const { options, currentYear: canonicalCurrentYear } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: selectedYear, + }) const load = useCallback(() => { setLoading(true) @@ -94,19 +99,19 @@ export function InventorySummaryPage() { value={ selectedYear.toLowerCase() === 'all' ? 'all' - : selectedYear === currentYear && !searchParams.get('school_year') + : selectedYear === (currentYear || canonicalCurrentYear) && !searchParams.get('school_year') ? '__current__' : selectedYear } onChange={(e) => onYearFilter(e.target.value)} > - + - {schoolYears - .filter((y) => y !== currentYear) - .map((y) => ( - ))} diff --git a/src/pages/inventory/TeacherBookDistributePage.tsx b/src/pages/inventory/TeacherBookDistributePage.tsx index 813bac3..eff6128 100644 --- a/src/pages/inventory/TeacherBookDistributePage.tsx +++ b/src/pages/inventory/TeacherBookDistributePage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useCallback, useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchTeacherBookDistribute, postTeacherBookDistribute } from '../../api/inventory' type BookGroup = { label: string; items: Record[] } @@ -23,6 +24,9 @@ export function TeacherBookDistributePage() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const { selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear, + }) const load = useCallback(() => { setLoading(true) @@ -74,6 +78,8 @@ export function TeacherBookDistributePage() { setSelected((prev) => ({ ...prev, [k]: checked })) } + const effectiveSchoolYear = schoolYear || selectedYear + function checkAll(on: boolean) { const next: Record = {} if (on) { @@ -179,7 +185,7 @@ export function TeacherBookDistributePage() {
- School Year: {schoolYear}, Semester: {semester} + School Year: {effectiveSchoolYear || '—'}, Semester: {semester}
On Hand: {onHand} @@ -239,7 +245,7 @@ export function TeacherBookDistributePage() { {String(student.semester ?? semester)} - {String(student.school_year ?? schoolYear)} + {String(student.school_year ?? effectiveSchoolYear)} {given ? ( diff --git a/src/pages/invoicePayment/InvoiceManagementPage.tsx b/src/pages/invoicePayment/InvoiceManagementPage.tsx index ddef626..4314974 100644 --- a/src/pages/invoicePayment/InvoiceManagementPage.tsx +++ b/src/pages/invoicePayment/InvoiceManagementPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchInvoiceManagement, type InvoiceManagementRow, @@ -37,6 +38,10 @@ export function InvoiceManagementPage() { const [generating, setGenerating] = useState(null) const [error, setError] = useState(null) const [toast, setToast] = useState<{ ok: boolean; message: string } | null>(null) + const { options, selectedYear: defaultYear } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: schoolYear, + }) const load = useCallback(async (year?: string) => { setLoading(true) @@ -47,7 +52,7 @@ export function InvoiceManagementPage() { const data = await fetchInvoiceManagement(sp) const years = Array.isArray(data.schoolYears) ? data.schoolYears : [] setSchoolYears(years) - const sel = data.schoolYear ?? years[0] ?? '' + const sel = data.schoolYear ?? years[0] ?? defaultYear setSchoolYear(sel) setInvoices(Array.isArray(data.invoices) ? data.invoices : []) } catch (e: unknown) { @@ -56,7 +61,7 @@ export function InvoiceManagementPage() { } finally { setLoading(false) } - }, []) + }, [defaultYear]) useEffect(() => { void load() @@ -133,9 +138,9 @@ export function InvoiceManagementPage() { value={schoolYear} onChange={(e) => void onYearChange(e.target.value)} > - {schoolYears.map((y) => ( - ))} diff --git a/src/pages/payment/ExtraChargesPage.tsx b/src/pages/payment/ExtraChargesPage.tsx index e7ee540..7ec49d1 100644 --- a/src/pages/payment/ExtraChargesPage.tsx +++ b/src/pages/payment/ExtraChargesPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchChargeInvoicesForParent, fetchExtraChargesPage, @@ -33,6 +34,10 @@ export function ExtraChargesPage() { const [invoiceOptions, setInvoiceOptions] = useState>([]) const [modalOpen, setModalOpen] = useState(false) const [selectedParentId, setSelectedParentId] = useState('') + const { options, selectedYear: defaultYear } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: activeYear || schoolYear, + }) useEffect(() => { setActiveYear(schoolYear) @@ -101,15 +106,6 @@ export function ExtraChargesPage() { let pageTotal = 0 for (const r of rows) pageTotal += Number(r.amount ?? 0) - const years = - schoolYears.length > 0 - ? schoolYears - : (() => { - const out: string[] = [] - for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`) - return out - })() - return (
{msg ?
{msg}
: null} @@ -130,12 +126,12 @@ export function ExtraChargesPage() { id="schoolYearSel" className="form-select form-select-sm rounded-pill px-3" style={{ minWidth: 180 }} - value={activeYear || years[0] || ''} + value={activeYear || defaultYear} onChange={onYearChange} > - {years.map((y) => ( - ))} diff --git a/src/pages/payment/FinancialReportPage.tsx b/src/pages/payment/FinancialReportPage.tsx index e572181..7d45bf5 100644 --- a/src/pages/payment/FinancialReportPage.tsx +++ b/src/pages/payment/FinancialReportPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { downloadFinancialReportCsv, fetchFinancialReportDetailed, @@ -29,6 +30,10 @@ export function FinancialReportPage() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [lastRef, setLastRef] = useState(null) + const { options, selectedYear: defaultYear } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: schoolYear, + }) const hydrate = useCallback(() => { setLoading(true) @@ -133,15 +138,6 @@ export function FinancialReportPage() { const totalCheck = Number(pt.total_check ?? 0) const grandTotal = Number(pt.total_all ?? totalCash + totalCredit + totalCheck) - const yearOptions = - schoolYears.length > 0 - ? schoolYears - : (() => { - const out: string[] = [] - for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`) - return out - })() - return (

Detailed Financial Report

@@ -158,12 +154,12 @@ export function FinancialReportPage() { name="school_year" className="form-select form-select-sm rounded-pill px-3" style={{ minWidth: 180 }} - value={schoolYear || yearOptions[0] || ''} + value={schoolYear || defaultYear} onChange={(e) => setSchoolYear(e.target.value)} > - {yearOptions.map((sy) => ( - ))} @@ -221,7 +217,7 @@ export function FinancialReportPage() { Display Summary Report diff --git a/src/pages/payment/FinancialReportSummaryPage.tsx b/src/pages/payment/FinancialReportSummaryPage.tsx index a1a4f33..b541ab6 100644 --- a/src/pages/payment/FinancialReportSummaryPage.tsx +++ b/src/pages/payment/FinancialReportSummaryPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useEffect, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { downloadFinancialSummaryPdf, fetchFinancialReportSummary, @@ -19,6 +20,9 @@ export function FinancialReportSummaryPage() { const [d, setD] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYear || urlYear, + }) function load(y: string) { setLoading(true) @@ -87,7 +91,7 @@ export function FinancialReportSummaryPage() { name="school_year" className="form-select form-select-sm rounded-pill px-3" style={{ minWidth: 180 }} - value={schoolYear} + value={schoolYear || selectedYear} onChange={(e) => { const y = e.target.value setSchoolYear(y) @@ -96,15 +100,11 @@ export function FinancialReportSummaryPage() { setSearchParams(next) }} > - {(() => { - const years: string[] = [] - for (let y = new Date().getFullYear(); y >= 2020; y--) years.push(`${y}-${y + 1}`) - return years.map((y) => ( - - )) - })()} + {(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => ( + + ))}
diff --git a/src/pages/reimbursements/ReimbursementsIndexPage.tsx b/src/pages/reimbursements/ReimbursementsIndexPage.tsx index 0967654..b562bc0 100644 --- a/src/pages/reimbursements/ReimbursementsIndexPage.tsx +++ b/src/pages/reimbursements/ReimbursementsIndexPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { downloadBatchCsv, downloadReimbursementsExport, @@ -92,6 +93,10 @@ export function ReimbursementsIndexPage() { } const schoolYears = data?.schoolYears ?? [] + const { options } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: schoolYear, + }) const users = data?.users ?? [] const batchSummaries = (data?.batchSummaries ?? []) as BatchSummaryRow[] const batchDetails = data?.batchDetails ?? {} @@ -188,9 +193,9 @@ export function ReimbursementsIndexPage() { diff --git a/src/pages/report/CombinedReportPage.tsx b/src/pages/report/CombinedReportPage.tsx index cd0c6d9..00ff315 100644 --- a/src/pages/report/CombinedReportPage.tsx +++ b/src/pages/report/CombinedReportPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchCombinedReport } from '../../api/reportsCombined' import { REPORT_COMBINED_PATH } from './reportPaths' @@ -275,6 +276,10 @@ export function CombinedReportPage() { cursor: 'pointer', userSelect: 'none' as const, } + const { options, selectedYear: defaultYear } = useSchoolYearOptions({ + preferredYear: selectedYear, + }) + const schoolYearOptions = options.length > 0 ? options : [{ value: defaultYear, label: defaultYear }] return (
@@ -289,14 +294,13 @@ export function CombinedReportPage() {
- +
diff --git a/src/pages/rfid/BadgeScanLogsPage.tsx b/src/pages/rfid/BadgeScanLogsPage.tsx index f60e5ff..2b3fe97 100644 --- a/src/pages/rfid/BadgeScanLogsPage.tsx +++ b/src/pages/rfid/BadgeScanLogsPage.tsx @@ -5,6 +5,7 @@ import { fetchBadgeScanLogs, type BadgeScanLogRow, } from '../../api/badgeScan' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' function formatDateTime(raw: string | null): string { if (!raw) return '—' @@ -80,6 +81,10 @@ export function BadgeScanLogsPage() { () => [...new Set(logs.map((r) => r.school_year).filter(Boolean))].sort().reverse() as string[], [logs], ) + const { options } = useSchoolYearOptions({ + legacyYears: schoolYears, + preferredYear: schoolYear, + }) return (
@@ -94,8 +99,8 @@ export function BadgeScanLogsPage() {
diff --git a/src/pages/scoreAnalysis/ScorePredictionPage.tsx b/src/pages/scoreAnalysis/ScorePredictionPage.tsx index 26300bb..80ddc38 100644 --- a/src/pages/scoreAnalysis/ScorePredictionPage.tsx +++ b/src/pages/scoreAnalysis/ScorePredictionPage.tsx @@ -1,6 +1,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' +import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions' import { fetchScorePrediction, type ScorePredictionStudent } from '../../api/scoreAnalysis' import { FailRiskLabel, StatusBadge, TrophyChanceLabel } from './scorePredictionUi' @@ -15,6 +16,9 @@ export function ScorePredictionPage() { const [data, setData] = useState> | null>(null) const [error, setError] = useState(null) + const { options, selectedYear } = useSchoolYearOptions({ + preferredYear: schoolYearParam, + }) useEffect(() => { setError(null) @@ -125,6 +129,7 @@ export function ScorePredictionPage() { } const syDefault = data?.school_year ?? schoolYearParam + const schoolYearOptions = options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }] const statusColors = ['#198754', '#dc3545', '#0d6efd', '#ffc107', '#6c757d'] const riskColors = ['#198754', '#ffc107', '#dc3545', '#6610f2', '#adb5bd'] @@ -148,13 +153,13 @@ export function ScorePredictionPage() { - +