add school year model
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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 : []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user