add school year model

This commit is contained in:
root
2026-06-07 00:52:04 -04:00
parent c4d7a06a17
commit 8e79201a3c
32 changed files with 2031 additions and 213 deletions
+6
View File
@@ -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() {
<Route path="administrator/communications" element={<CommunicationsIndexPage />} />
<Route path="administrator/configuration_view" element={<ConfigurationViewPage />} />
<Route path="administrator/configuration" element={<Navigate to="configuration_view" replace />} />
<Route path="administrator/school-years" element={<SchoolYearsManagementPage />} />
<Route path="administrator/discounts" element={<DiscountListPage />} />
<Route path="administrator/discounts/create" element={<DiscountCreatePage />} />
<Route path="administrator/discounts/apply" element={<DiscountApplyVoucherPage />} />
+294
View File
@@ -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<string, number>
balance_counts?: Record<string, number>
}
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<SchoolYearRecord[]> {
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
return response.data ?? []
}
export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>('/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<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/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<SchoolYearSummary> {
const response = await apiFetch<ApiSummaryResponse>(`/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<SchoolYearCloseValidation> {
const response = await apiFetch<ApiValidationResponse>(
`/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<SchoolYearClosePreview> {
const response = await apiFetch<ApiPreviewResponse>(
`/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<SchoolYearCloseResult> {
const response = await apiFetch<ApiCloseResponse>(`/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<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/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<SchoolYearParentBalancePreview> {
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
const response = await apiFetch<ApiParentBalancesResponse>(
`/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<SchoolYearPromotionPreview> {
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
const response = await apiFetch<ApiPromotionPreviewResponse>(
`/api/v1/school-years/${schoolYearId}/promotion-preview${query}`,
)
if (!response.data) {
throw new Error('Promotion preview returned no payload.')
}
return response.data
}
+16 -11
View File
@@ -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<string | { school_year: string }>
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) => (
<option key={year} value={year}>
{year}
{options.length > 0 ? (
options.map((year) => (
<option key={year.value} value={year.value}>
{year.label}
</option>
))
) : (
@@ -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
+88
View File
@@ -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<SchoolYearRecord[]> | null = null
async function loadCanonicalSchoolYears(): Promise<SchoolYearRecord[]> {
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<SchoolYearRecord[]>(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,
}
}
+142
View File
@@ -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<string | null | undefined>
}): SchoolYearOption[] {
const map = new Map<string, SchoolYearOption>()
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
}
+30 -15
View File
@@ -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<string | null>(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() {
>
<div>
<label htmlFor="cal-school-year" className="form-label">School Year</label>
<input id="cal-school-year" name="school_year" className="form-control" defaultValue={schoolYear} placeholder="e.g. 2025-2026" />
<select
id="cal-school-year"
name="school_year"
className="form-select"
defaultValue={schoolYear || currentYear}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="d-flex gap-2 align-self-end">
<button className="btn btn-secondary" type="submit">Apply</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }))}
onClick={() =>
setSearchParams(new URLSearchParams(currentYear ? { school_year: currentYear } : {}))
}
>
Reset
</button>
@@ -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() {
<select
className="form-select form-select-sm"
style={{ minWidth: 200 }}
value={selectedYear}
value={selectedYear || defaultYear}
onChange={(event) => setSelectedYear(event.target.value)}
>
{schoolYears.map((year) => (
<option key={year} value={year}>{year}</option>
{options.map((year) => (
<option key={year.value} value={year.value}>{year.label}</option>
))}
</select>
</div>
File diff suppressed because it is too large Load Diff
@@ -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<string | null>(null)
const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
@@ -143,13 +147,13 @@ export function AdminsAttendanceFormPage() {
>
<div className="col-sm-3">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-sm-2">
<label className="form-label">Semester</label>
+11 -7
View File
@@ -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<string | null>(null)
const [flash, setFlash] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
@@ -90,13 +94,13 @@ export function EarlyDismissalsPage() {
>
<div className="col-md-4">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Semester</label>
@@ -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<ParentAttendanceAdminReportRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
@@ -63,13 +67,13 @@ export function ParentAttendanceReportsAdminPage() {
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={apply}>
<div className="col-md-3">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-md-2">
<label className="form-label">Semester</label>
@@ -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<string | null>(null)
const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
@@ -136,13 +140,13 @@ export function TeacherAttendanceFormPage() {
<form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}>
<div className="col-sm-3">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-sm-2">
<label className="form-label">Semester</label>
@@ -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() {
<div className="col-md-3">
<label className="form-label">School Year</label>
<select name="school_year" className="form-select" defaultValue={schoolYearParam}>
{schoolYears.map((y) => (
<option key={y} value={y}>
{y}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+31 -2
View File
@@ -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() {
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto">
<label htmlFor="pending-school-year" className="form-label small mb-0">School Year</label>
<input id="pending-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
<select
id="pending-school-year"
name="school_year"
className="form-select form-select-sm"
defaultValue={schoolYear || selectedYear}
>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-auto">
<label htmlFor="pending-semester" className="form-label small mb-0">Semester</label>
@@ -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() {
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto">
<label htmlFor="notified-school-year" className="form-label small mb-0">School Year</label>
<input id="notified-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
<select
id="notified-school-year"
name="school_year"
className="form-select form-select-sm"
defaultValue={schoolYear || selectedYear}
>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-auto">
<label htmlFor="notified-semester" className="form-label small mb-0">Semester</label>
+11 -18
View File
@@ -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<ClassPrepIndexResponse | null>(null)
@@ -24,19 +20,16 @@ export function ClassPrepListPage() {
const [error, setError] = useState<string | null>(null)
const [savingId, setSavingId] = useState<string | null>(null)
const [saveMsg, setSaveMsg] = useState<string | null>(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) => (
<option key={y} value={y}>
{y}
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+29 -25
View File
@@ -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<HTMLFormElement>) {
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) => (
<option key={y} value={y}>
{y}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
@@ -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)}
>
<option value=""></option>
<option value="Fall">Fall</option>
@@ -77,7 +77,11 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
<button
type="button"
className="btn btn-outline-secondary btn-sm ms-1"
onClick={() => navigate({ pathname, search: '' })}
onClick={() => {
setYearValue(selectedYear)
setSemesterValue('')
navigate({ pathname, search: '' })
}}
>
Reset
</button>
@@ -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<Record<number, string>>({})
const [pendingClass, setPendingClass] = useState<Record<number, string>>({})
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) => (
<option key={y} value={y}>
{y}
{options.length > 0 ? (
options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
+21 -6
View File
@@ -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<string | null>(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<string, unknown> = {
class_section_id: classSectionId ? Number(classSectionId) : undefined,
school_year: schoolYear || undefined,
school_year: effectiveSchoolYear || undefined,
semester: semester || undefined,
scores: rows.reduce<Record<string, { score: number | string | null }>>((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<string, unknown>
const students = Array.isArray(o.students) ? o.students : []
+12 -7
View File
@@ -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<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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)}
>
<option value="__current__">Current ({currentYear || '—'})</option>
<option value="__current__">Current ({currentYear || canonicalCurrentYear || '—'})</option>
<option value="all">All Years</option>
{schoolYears
.filter((y) => y !== currentYear)
.map((y) => (
<option key={y} value={y}>
{y}
{options
.filter((option) => option.value !== (currentYear || canonicalCurrentYear))
.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
@@ -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<string, unknown>[] }
@@ -23,6 +24,9 @@ export function TeacherBookDistributePage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(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<string, boolean> = {}
if (on) {
@@ -179,7 +185,7 @@ export function TeacherBookDistributePage() {
<div className="alert alert-info py-2">
<div className="d-flex justify-content-between flex-wrap gap-2">
<div>
<strong>School Year:</strong> {schoolYear}, <strong>Semester:</strong> {semester}
<strong>School Year:</strong> {effectiveSchoolYear || '—'}, <strong>Semester:</strong> {semester}
</div>
<div>
<strong>On Hand:</strong> {onHand}
@@ -239,7 +245,7 @@ export function TeacherBookDistributePage() {
{String(student.semester ?? semester)}
</td>
<td style={{ textAlign: 'center' }}>
{String(student.school_year ?? schoolYear)}
{String(student.school_year ?? effectiveSchoolYear)}
</td>
<td style={{ textAlign: 'center' }}>
{given ? (
@@ -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<number | null>(null)
const [error, setError] = useState<string | null>(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) => (
<option key={y} value={y}>
{y}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+9 -13
View File
@@ -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<Array<{ id?: number; invoice_number?: string }>>([])
const [modalOpen, setModalOpen] = useState(false)
const [selectedParentId, setSelectedParentId] = useState<string>('')
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 (
<div className="container-fluid py-4">
{msg ? <div className="alert alert-info">{msg}</div> : 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) => (
<option key={y} value={y}>
{y}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+10 -14
View File
@@ -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<string | null>(null)
const [lastRef, setLastRef] = useState<string | null>(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 (
<div className="container-fluid mt-4">
<h2 className="text-center mt-4 mb-3">Detailed Financial Report</h2>
@@ -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) => (
<option key={sy} value={sy}>
{sy}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
@@ -221,7 +217,7 @@ export function FinancialReportPage() {
</button>
<Link
className="btn btn-info"
to={`/app/administrator/payment/financial-report-summary?school_year=${encodeURIComponent(schoolYear || yearOptions[0] || '')}`}
to={`/app/administrator/payment/financial-report-summary?school_year=${encodeURIComponent(schoolYear || defaultYear)}`}
>
Display Summary Report
</Link>
@@ -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<FinancialSummaryJson | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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) => (
<option key={y} value={y}>
{y}
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
})()}
))}
</select>
</form>
<div className="d-flex align-items-center gap-2 flex-nowrap flex-shrink-0">
@@ -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() {
<label className="form-label">School Year</label>
<select name="school_year" className="form-select" defaultValue={schoolYear}>
<option value="">All</option>
{schoolYears.map((sy) => (
<option key={sy} value={sy}>
{sy}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+12 -8
View File
@@ -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 (
<div className="container-fluid py-4">
@@ -289,14 +294,13 @@ export function CombinedReportPage() {
<form className="row g-3 mb-4 align-items-end" onSubmit={applyFilter}>
<div className="col-md-6 col-lg-4">
<label htmlFor="school_year" className="form-label">School Year</label>
<input
id="school_year"
type="text"
name="school_year"
className="form-control"
defaultValue={selectedYear}
placeholder="e.g. 2025-2026"
/>
<select id="school_year" name="school_year" className="form-select" defaultValue={selectedYear || defaultYear}>
{schoolYearOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-md-6 col-lg-4">
+7 -2
View File
@@ -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 (
<div className="container-fluid px-0">
@@ -94,8 +99,8 @@ export function BadgeScanLogsPage() {
<label htmlFor="bsl-school-year" className="form-label small mb-0">School Year</label>
<select id="bsl-school-year" name="school_year" className="form-select form-select-sm" defaultValue={schoolYear}>
<option value=""> All </option>
{schoolYears.map((sy) => (
<option key={sy} value={sy}>{sy}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
@@ -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<Awaited<ReturnType<typeof fetchScorePrediction>> | null>(null)
const [error, setError] = useState<string | null>(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() {
<label htmlFor="school_year" className="form-label">
School Year
</label>
<input
type="text"
className="form-control"
id="school_year"
name="school_year"
defaultValue={syDefault}
/>
<select className="form-select" id="school_year" name="school_year" defaultValue={syDefault || selectedYear}>
{schoolYearOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-md-6 col-lg-4">
<label htmlFor="class_section_id" className="form-label">
+11 -7
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchSlipPreviewList, type SlipPreviewRow } from '../../api/slips'
import { displaySemester, formatPreviewDateKey } from './slipFormatting'
import { SLIPS_PRINT_PATH } from './slipPaths'
@@ -60,6 +61,9 @@ export function SlipPreviewListPage() {
key: 'student_name',
direction: 'asc',
})
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
setError(null)
@@ -159,13 +163,13 @@ export function SlipPreviewListPage() {
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}>
<div className="col-md-4">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Semester</label>
+15 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useAuth } from '../../auth/AuthProvider'
import { fetchSchoolYears } from '../../api/schoolYears'
import {
fetchCompetitionScoresIndex,
fetchParentEnrollments,
@@ -16,6 +17,7 @@ import {
semesterHeaderLabel,
sortSemesterKeys,
} from './scoreCardListUtils'
import { canonicalSchoolYearName } from '../../lib/schoolYearOptions'
const LS_CLASS = 'alrahma_teacher_score_card_class_section_id'
const LS_YEAR = 'alrahma_teacher_score_card_school_year'
@@ -41,16 +43,17 @@ async function resolveTeacherScoreCardContext(searchParams: URLSearchParams): Pr
classSectionId: number
schoolYear: string | null
}> {
const canonicalSchoolYear = await resolveDefaultSchoolYear()
const fromUrlC = Number(searchParams.get('class_section_id') ?? '0')
const fromUrlY = searchParams.get('school_year')?.trim() || null
if (fromUrlC > 0) {
return { classSectionId: fromUrlC, schoolYear: fromUrlY }
return { classSectionId: fromUrlC, schoolYear: fromUrlY ?? canonicalSchoolYear }
}
try {
const lsC = Number(localStorage.getItem(LS_CLASS) ?? '0')
const lsY = localStorage.getItem(LS_YEAR)?.trim() || null
if (lsC > 0) {
return { classSectionId: lsC, schoolYear: fromUrlY ?? lsY }
return { classSectionId: lsC, schoolYear: fromUrlY ?? lsY ?? canonicalSchoolYear }
}
} catch {
/* ignore */
@@ -63,16 +66,16 @@ async function resolveTeacherScoreCardContext(searchParams: URLSearchParams): Pr
if (cid > 0) {
try {
localStorage.setItem(LS_CLASS, String(cid))
if (sy) localStorage.setItem(LS_YEAR, sy)
if (sy ?? canonicalSchoolYear) localStorage.setItem(LS_YEAR, sy ?? canonicalSchoolYear ?? '')
} catch {
/* ignore */
}
return { classSectionId: cid, schoolYear: sy }
return { classSectionId: cid, schoolYear: sy ?? canonicalSchoolYear }
}
} catch {
/* ignore */
}
return { classSectionId: 0, schoolYear: fromUrlY }
return { classSectionId: 0, schoolYear: fromUrlY ?? canonicalSchoolYear }
}
function filterStudentsToClassSection(
@@ -86,6 +89,13 @@ function filterStudentsToClassSection(
}
async function resolveDefaultSchoolYear(): Promise<string | null> {
try {
const years = await fetchSchoolYears()
const canonical = canonicalSchoolYearName(years)
if (canonical) return canonical
} catch {
/* ignore */
}
try {
const comp = await fetchCompetitionScoresIndex(null)
const sy = comp.schoolYear != null && String(comp.schoolYear).trim() !== '' ? String(comp.schoolYear).trim() : null
+20 -3
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import {
fetchTeacherDashboard,
isTeacherDashboardApiEnabled,
type TeacherDashboardPayload,
} from '../../api/teacherSession'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
@@ -18,6 +19,16 @@ type SentPayload = { sentMessages?: MsgRow[] }
type TeacherMessagePayload = { message?: Record<string, unknown>; thread?: unknown[] }
function appendSchoolYearParam(path: string, schoolYear: string): string {
if (!schoolYear) return path
const [base, hash = ''] = path.split('#', 2)
const [pathname, query = ''] = base.split('?', 2)
const params = new URLSearchParams(query)
if (!params.get('school_year')) params.set('school_year', schoolYear)
const next = `${pathname}?${params.toString()}`
return hash ? `${next}#${hash}` : next
}
/** Every `/app/teacher/*` route so nothing is hidden behind the slim top nav alone. */
const TEACHER_PAGE_GROUPS = [
{
@@ -204,14 +215,20 @@ export function TeacherNoClassesPage() {
/** `teacher/select_semester.php` */
export function TeacherSelectSemesterPage() {
const [searchParams] = useSearchParams()
const { data, loading, error } = useTeacherResource<{
invalidChoice?: string
fallPath?: string
springPath?: string
}>('/select-semester')
const requestedSchoolYear = searchParams.get('school_year')?.trim() ?? ''
const { selectedYear } = useSchoolYearOptions({
preferredYear: requestedSchoolYear,
})
const activeSchoolYear = requestedSchoolYear || selectedYear
const fallTo = data?.fallPath ?? '/app/teacher/scores?semester=Fall'
const springTo = data?.springPath ?? '/app/teacher/scores?semester=Spring'
const fallTo = appendSchoolYearParam(data?.fallPath ?? '/app/teacher/scores?semester=Fall', activeSchoolYear)
const springTo = appendSchoolYearParam(data?.springPath ?? '/app/teacher/scores?semester=Spring', activeSchoolYear)
return (
<TeacherShell title="Choose a semester" loading={loading} error={error}>
+76 -10
View File
@@ -7,6 +7,7 @@ import { useTeacherResource } from './useTeacherResource'
import { postTeacherMultipart } from '../../api/teacherSession'
import { useAuth } from '../../auth/AuthProvider'
import { ApiHttpError, apiFetch } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
function TeacherApiDumpPage({
title,
@@ -29,6 +30,20 @@ function TeacherApiDumpPage({
)
}
function teacherRouteWithTerm(
path: string,
params: { classSectionId?: number; schoolYear?: string; semester?: string },
): string {
const query = new URLSearchParams()
if (params.classSectionId && params.classSectionId > 0) {
query.set('class_section_id', String(params.classSectionId))
}
if (params.schoolYear) query.set('school_year', params.schoolYear)
if (params.semester) query.set('semester', params.semester)
const value = query.toString()
return value ? `${path}?${value}` : path
}
type CompetitionIndexPayload = {
success?: string
error?: string
@@ -1372,6 +1387,10 @@ export function TeacherScoresPage() {
const [search] = useSearchParams()
const requestedSemester = search.get('semester')?.trim() ?? ''
const requestedSchoolYear = search.get('school_year')?.trim() ?? ''
const { selectedYear: defaultSchoolYear } = useSchoolYearOptions({
preferredYear: requestedSchoolYear,
})
const effectiveRequestedSchoolYear = requestedSchoolYear || defaultSchoolYear
const [payload, setPayload] = useState<TeacherScoresPayload | null>(null)
const [rows, setRows] = useState<TeacherScoresRow[]>([])
@@ -1384,7 +1403,7 @@ export function TeacherScoresPage() {
const [tableSearch, setTableSearch] = useState('')
const resolvedSemester = payload?.semester?.trim() || requestedSemester
const resolvedSchoolYear = payload?.school_year?.trim() || requestedSchoolYear
const resolvedSchoolYear = payload?.school_year?.trim() || effectiveRequestedSchoolYear
const semesterKey = teacherSemesterKey(resolvedSemester)
const termLabel = semesterKey === 'midterm' ? 'Midterm' : semesterKey === 'final' ? 'Final Exam' : 'Term Score'
const termCommentLabel = semesterKey === 'midterm' ? 'Midterm Comment' : semesterKey === 'final' ? 'Final Comment' : 'Term Comment'
@@ -1464,7 +1483,7 @@ export function TeacherScoresPage() {
setWarningLines([])
const params = new URLSearchParams()
if (requestedSemester !== '') params.set('semester', requestedSemester)
if (requestedSchoolYear !== '') params.set('school_year', requestedSchoolYear)
if (effectiveRequestedSchoolYear !== '') params.set('school_year', effectiveRequestedSchoolYear)
const query = params.toString()
const body = await apiFetch<TeacherScoresPayload>(`/api/v1/teacher/scores${query ? `?${query}` : ''}`)
setPayload(body)
@@ -1478,7 +1497,7 @@ export function TeacherScoresPage() {
useEffect(() => {
void load()
}, [requestedSchoolYear, requestedSemester])
}, [effectiveRequestedSchoolYear, requestedSemester])
const visibleRows = useMemo(() => {
const needle = tableSearch.trim().toLowerCase()
@@ -1674,7 +1693,12 @@ export function TeacherScoresPage() {
) : null}
<div className="text-center mb-3">
<Link to="/app/teacher/select-semester" className="btn btn-outline-secondary btn-sm">
<Link
to={teacherRouteWithTerm('/app/teacher/select-semester', {
schoolYear: resolvedSchoolYear,
})}
className="btn btn-outline-secondary btn-sm"
>
Choose another semester
</Link>
</div>
@@ -1807,25 +1831,67 @@ export function TeacherScoresPage() {
</div>
<div className="d-flex flex-wrap gap-2 mt-3">
<Link to={`/app/teacher/add-homework?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-homework', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Homework
</Link>
<Link to={`/app/teacher/add-quiz?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-quiz', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Quiz
</Link>
<Link to={`/app/teacher/add-project?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-project', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Project
</Link>
<Link to={`/app/teacher/add-participation?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-participation', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Participation
</Link>
{semesterKey === 'midterm' ? (
<Link to={`/app/teacher/add-midterm-exam?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-midterm-exam', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Midterm
</Link>
) : null}
{semesterKey === 'final' ? (
<Link to={`/app/teacher/add-final-exam?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-final-exam', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Final
</Link>
) : null}
+9 -3
View File
@@ -1,6 +1,7 @@
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchTrophyFinal,
fetchTrophyProjection,
@@ -62,14 +63,19 @@ function TrophyFilters({
selectedPercentile: number
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}) {
const { options } = useSchoolYearOptions({
legacyYears: years,
preferredYear: selectedYear,
})
return (
<form className="row g-3 align-items-end mb-4" onSubmit={onSubmit}>
<div className="col-md-4 col-lg-3">
<label className="form-label">School Year</label>
<select className="form-select" name="school_year" defaultValue={selectedYear}>
{(years.length > 0 ? years : [selectedYear]).map((year) => (
<option key={year} value={year}>
{year}
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((year) => (
<option key={year.value} value={year.value}>
{year.label}
</option>
))}
</select>