add school year model
This commit is contained in:
@@ -77,6 +77,11 @@ const ViewRouteSitemapPage = lazy(() =>
|
|||||||
const ViewRoutePlaceholderPage = lazy(() =>
|
const ViewRoutePlaceholderPage = lazy(() =>
|
||||||
import('./pages/ViewRoutePlaceholderPage').then((m) => ({ default: m.ViewRoutePlaceholderPage })),
|
import('./pages/ViewRoutePlaceholderPage').then((m) => ({ default: m.ViewRoutePlaceholderPage })),
|
||||||
)
|
)
|
||||||
|
const SchoolYearsManagementPage = lazy(() =>
|
||||||
|
import('./pages/administrator/SchoolYearsManagementPage').then((m) => ({
|
||||||
|
default: m.SchoolYearsManagementPage,
|
||||||
|
})),
|
||||||
|
)
|
||||||
const NavBuilderPage = lazy(() =>
|
const NavBuilderPage = lazy(() =>
|
||||||
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
|
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/communications" element={<CommunicationsIndexPage />} />
|
||||||
<Route path="administrator/configuration_view" element={<ConfigurationViewPage />} />
|
<Route path="administrator/configuration_view" element={<ConfigurationViewPage />} />
|
||||||
<Route path="administrator/configuration" element={<Navigate to="configuration_view" replace />} />
|
<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" element={<DiscountListPage />} />
|
||||||
<Route path="administrator/discounts/create" element={<DiscountCreatePage />} />
|
<Route path="administrator/discounts/create" element={<DiscountCreatePage />} />
|
||||||
<Route path="administrator/discounts/apply" element={<DiscountApplyVoucherPage />} />
|
<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 { Link } from 'react-router-dom'
|
||||||
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
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 = {
|
export type CiFlashMessage = {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -61,19 +63,22 @@ export function CiAcademicFilter({
|
|||||||
classSectionId,
|
classSectionId,
|
||||||
onApply,
|
onApply,
|
||||||
}: {
|
}: {
|
||||||
schoolYears?: Array<string | { school_year: string }>
|
schoolYears?: LegacySchoolYearValue[]
|
||||||
selectedYear?: string | null
|
selectedYear?: string | null
|
||||||
selectedSemester?: string | null
|
selectedSemester?: string | null
|
||||||
classSectionId?: string | number | null
|
classSectionId?: string | number | null
|
||||||
onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void
|
onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void
|
||||||
}) {
|
}) {
|
||||||
const years = (schoolYears ?? []).map((year) => (typeof year === 'string' ? year : year.school_year))
|
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
|
||||||
const [schoolYear, setSchoolYear] = useState(selectedYear ?? years[0] ?? '')
|
legacyYears: schoolYears,
|
||||||
|
preferredYear: selectedYear,
|
||||||
|
})
|
||||||
|
const [schoolYear, setSchoolYear] = useState(selectedYear ?? defaultYear)
|
||||||
const [semester, setSemester] = useState(selectedSemester ?? '')
|
const [semester, setSemester] = useState(selectedSemester ?? '')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSchoolYear(selectedYear ?? years[0] ?? '')
|
setSchoolYear(selectedYear ?? defaultYear)
|
||||||
}, [selectedYear, years.join('|')])
|
}, [defaultYear, selectedYear])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSemester(selectedSemester ?? '')
|
setSemester(selectedSemester ?? '')
|
||||||
@@ -104,10 +109,10 @@ export function CiAcademicFilter({
|
|||||||
value={schoolYear}
|
value={schoolYear}
|
||||||
onChange={(event) => setSchoolYear(event.target.value)}
|
onChange={(event) => setSchoolYear(event.target.value)}
|
||||||
>
|
>
|
||||||
{years.length > 0 ? (
|
{options.length > 0 ? (
|
||||||
years.map((year) => (
|
options.map((year) => (
|
||||||
<option key={year} value={year}>
|
<option key={year.value} value={year.value}>
|
||||||
{year}
|
{year.label}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -143,9 +148,9 @@ export function CiAcademicFilter({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-secondary btn-sm"
|
className="btn btn-outline-secondary btn-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSchoolYear(years[0] ?? '')
|
setSchoolYear(defaultYear)
|
||||||
setSemester('')
|
setSemester('')
|
||||||
onApply?.({ schoolYear: years[0] ?? '', semester: '', classSectionId: undefined })
|
onApply?.({ schoolYear: defaultYear, semester: '', classSectionId: undefined })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reset
|
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 { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import './AdminDailyAttendance.css'
|
import './AdminDailyAttendance.css'
|
||||||
import { ApiHttpError } from '../api/http'
|
import { ApiHttpError } from '../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
assignTeacherClass,
|
assignTeacherClass,
|
||||||
assignStudentClass,
|
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`. */
|
/** 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() {
|
export function AdminCalendarViewPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
@@ -1063,12 +1058,15 @@ export function AdminCalendarViewPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const schoolYear = searchParams.get('school_year') ?? ''
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const { options, currentYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!schoolYear) {
|
if (!schoolYear && currentYear) {
|
||||||
setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }), { replace: true })
|
setSearchParams(new URLSearchParams({ school_year: currentYear }), { replace: true })
|
||||||
}
|
}
|
||||||
}, [])
|
}, [currentYear, schoolYear, setSearchParams])
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -1124,14 +1122,27 @@ export function AdminCalendarViewPage() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="cal-school-year" className="form-label">School Year</label>
|
<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>
|
||||||
<div className="d-flex gap-2 align-self-end">
|
<div className="d-flex gap-2 align-self-end">
|
||||||
<button className="btn btn-secondary" type="submit">Apply</button>
|
<button className="btn btn-secondary" type="submit">Apply</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-secondary"
|
className="btn btn-outline-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }))}
|
onClick={() =>
|
||||||
|
setSearchParams(new URLSearchParams(currentYear ? { school_year: currentYear } : {}))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
@@ -1307,6 +1318,10 @@ export function AdminClassAssignmentPage() {
|
|||||||
key: 'school_id',
|
key: 'school_id',
|
||||||
direction: 'asc',
|
direction: 'asc',
|
||||||
})
|
})
|
||||||
|
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
|
||||||
|
legacyYears: schoolYears,
|
||||||
|
preferredYear: selectedYear,
|
||||||
|
})
|
||||||
|
|
||||||
async function load(year?: string | null) {
|
async function load(year?: string | null) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -1316,7 +1331,7 @@ export function AdminClassAssignmentPage() {
|
|||||||
const classSections = data.classSections ?? []
|
const classSections = data.classSections ?? []
|
||||||
setSections(classSections)
|
setSections(classSections)
|
||||||
setSchoolYears(data.schoolYears ?? [])
|
setSchoolYears(data.schoolYears ?? [])
|
||||||
setSelectedYear(data.selectedYear ?? data.schoolYear ?? '')
|
setSelectedYear(data.selectedYear ?? data.schoolYear ?? defaultYear)
|
||||||
setSelectedSemester(data.selectedSemester ?? data.semester ?? '')
|
setSelectedSemester(data.selectedSemester ?? data.semester ?? '')
|
||||||
setExpandedSections(new Set(classSections.length > 0 ? ['0'] : []))
|
setExpandedSections(new Set(classSections.length > 0 ? ['0'] : []))
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -1397,11 +1412,11 @@ export function AdminClassAssignmentPage() {
|
|||||||
<select
|
<select
|
||||||
className="form-select form-select-sm"
|
className="form-select form-select-sm"
|
||||||
style={{ minWidth: 200 }}
|
style={{ minWidth: 200 }}
|
||||||
value={selectedYear}
|
value={selectedYear || defaultYear}
|
||||||
onChange={(event) => setSelectedYear(event.target.value)}
|
onChange={(event) => setSelectedYear(event.target.value)}
|
||||||
>
|
>
|
||||||
{schoolYears.map((year) => (
|
{options.map((year) => (
|
||||||
<option key={year} value={year}>{year}</option>
|
<option key={year.value} value={year.value}>{year.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
fetchAdministratorAdminAttendance,
|
fetchAdministratorAdminAttendance,
|
||||||
saveAdministratorAdminAttendance,
|
saveAdministratorAdminAttendance,
|
||||||
@@ -26,6 +27,9 @@ export function AdminsAttendanceFormPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -143,13 +147,13 @@ export function AdminsAttendanceFormPage() {
|
|||||||
>
|
>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-3">
|
||||||
<label className="form-label">School Year</label>
|
<label className="form-label">School Year</label>
|
||||||
<input
|
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
|
||||||
type="text"
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
||||||
name="school_year"
|
<option key={option.value} value={option.value}>
|
||||||
className="form-control"
|
{option.label}
|
||||||
defaultValue={schoolYear}
|
</option>
|
||||||
placeholder="e.g. 2025-2026"
|
))}
|
||||||
/>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-2">
|
<div className="col-sm-2">
|
||||||
<label className="form-label">Semester</label>
|
<label className="form-label">Semester</label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useState } from 'react'
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { fetchEarlyDismissals, uploadEarlyDismissalSignature } from '../../api/session'
|
import { fetchEarlyDismissals, uploadEarlyDismissalSignature } from '../../api/session'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { apiUrl } from '../../lib/apiOrigin'
|
import { apiUrl } from '../../lib/apiOrigin'
|
||||||
import { EARLY_DISMISSALS_NEW_PATH, EARLY_DISMISSALS_PATH } from './attendancePaths'
|
import { EARLY_DISMISSALS_NEW_PATH, EARLY_DISMISSALS_PATH } from './attendancePaths'
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ export function EarlyDismissalsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [flash, setFlash] = useState<string | null>(null)
|
const [flash, setFlash] = useState<string | null>(null)
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -90,13 +94,13 @@ export function EarlyDismissalsPage() {
|
|||||||
>
|
>
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
<label className="form-label">School Year</label>
|
<label className="form-label">School Year</label>
|
||||||
<input
|
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
|
||||||
type="text"
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
||||||
name="school_year"
|
<option key={option.value} value={option.value}>
|
||||||
className="form-control"
|
{option.label}
|
||||||
defaultValue={schoolYear}
|
</option>
|
||||||
placeholder="e.g. 2025-2026"
|
))}
|
||||||
/>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
<label className="form-label">Semester</label>
|
<label className="form-label">Semester</label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type FormEvent, useEffect, useState } from 'react'
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { fetchParentAttendanceReportsAdmin } from '../../api/session'
|
import { fetchParentAttendanceReportsAdmin } from '../../api/session'
|
||||||
import type { ParentAttendanceAdminReportRow } from '../../api/types'
|
import type { ParentAttendanceAdminReportRow } from '../../api/types'
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ export function ParentAttendanceReportsAdminPage() {
|
|||||||
const [rows, setRows] = useState<ParentAttendanceAdminReportRow[]>([])
|
const [rows, setRows] = useState<ParentAttendanceAdminReportRow[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -63,13 +67,13 @@ export function ParentAttendanceReportsAdminPage() {
|
|||||||
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={apply}>
|
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={apply}>
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
<label className="form-label">School Year</label>
|
<label className="form-label">School Year</label>
|
||||||
<input
|
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
|
||||||
type="text"
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
||||||
name="school_year"
|
<option key={option.value} value={option.value}>
|
||||||
className="form-control"
|
{option.label}
|
||||||
defaultValue={schoolYear}
|
</option>
|
||||||
placeholder="e.g. 2025-2026"
|
))}
|
||||||
/>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-2">
|
<div className="col-md-2">
|
||||||
<label className="form-label">Semester</label>
|
<label className="form-label">Semester</label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session'
|
import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session'
|
||||||
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
||||||
import type { TeacherStaffAttendanceRow } from '../../api/types'
|
import type { TeacherStaffAttendanceRow } from '../../api/types'
|
||||||
@@ -26,6 +27,9 @@ export function TeacherAttendanceFormPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -136,13 +140,13 @@ export function TeacherAttendanceFormPage() {
|
|||||||
<form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}>
|
<form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-3">
|
||||||
<label className="form-label">School Year</label>
|
<label className="form-label">School Year</label>
|
||||||
<input
|
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
|
||||||
type="text"
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
||||||
name="school_year"
|
<option key={option.value} value={option.value}>
|
||||||
className="form-control"
|
{option.label}
|
||||||
defaultValue={schoolYear}
|
</option>
|
||||||
placeholder="e.g. 2025-2026"
|
))}
|
||||||
/>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-2">
|
<div className="col-sm-2">
|
||||||
<label className="form-label">Semester</label>
|
<label className="form-label">Semester</label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { apiUrl } from '../../lib/apiOrigin'
|
import { apiUrl } from '../../lib/apiOrigin'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
fetchStaffMonthlyAttendanceOverview,
|
fetchStaffMonthlyAttendanceOverview,
|
||||||
saveStaffMonthlyAttendanceCell,
|
saveStaffMonthlyAttendanceCell,
|
||||||
@@ -104,6 +105,10 @@ export function TeacherAttendanceMonthPage() {
|
|||||||
if (ys.length === 0 && schoolYearParam) ys.push(schoolYearParam)
|
if (ys.length === 0 && schoolYearParam) ys.push(schoolYearParam)
|
||||||
return ys
|
return ys
|
||||||
}, [payload?.schoolYears, schoolYearParam])
|
}, [payload?.schoolYears, schoolYearParam])
|
||||||
|
const { options } = useSchoolYearOptions({
|
||||||
|
legacyYears: schoolYears,
|
||||||
|
preferredYear: schoolYearParam,
|
||||||
|
})
|
||||||
|
|
||||||
const hasTeacherSections = (payload?.sections?.length ?? 0) > 0
|
const hasTeacherSections = (payload?.sections?.length ?? 0) > 0
|
||||||
const hasAdmins = (payload?.admins?.length ?? 0) > 0
|
const hasAdmins = (payload?.admins?.length ?? 0) > 0
|
||||||
@@ -202,9 +207,9 @@ export function TeacherAttendanceMonthPage() {
|
|||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
<label className="form-label">School Year</label>
|
<label className="form-label">School Year</label>
|
||||||
<select name="school_year" className="form-select" defaultValue={schoolYearParam}>
|
<select name="school_year" className="form-select" defaultValue={schoolYearParam}>
|
||||||
{schoolYears.map((y) => (
|
{options.map((option) => (
|
||||||
<option key={y} value={y}>
|
<option key={option.value} value={option.value}>
|
||||||
{y}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
fetchAttendanceViolationsNotified,
|
fetchAttendanceViolationsNotified,
|
||||||
fetchAttendanceViolationsPending,
|
fetchAttendanceViolationsPending,
|
||||||
@@ -29,6 +30,9 @@ export function ViolationsPendingPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const schoolYear = searchParams.get('school_year') ?? ''
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
const semester = searchParams.get('semester') ?? ''
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
const filterQuery = useMemo(() => {
|
const filterQuery = useMemo(() => {
|
||||||
const s = searchParams.toString()
|
const s = searchParams.toString()
|
||||||
@@ -94,7 +98,18 @@ export function ViolationsPendingPage() {
|
|||||||
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<label htmlFor="pending-school-year" className="form-label small mb-0">School Year</label>
|
<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>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<label htmlFor="pending-semester" className="form-label small mb-0">Semester</label>
|
<label htmlFor="pending-semester" className="form-label small mb-0">Semester</label>
|
||||||
@@ -315,6 +330,9 @@ export function ViolationsNotifiedPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const schoolYear = searchParams.get('school_year') ?? ''
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
const semester = searchParams.get('semester') ?? ''
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
const filterQuery = useMemo(() => {
|
const filterQuery = useMemo(() => {
|
||||||
const s = searchParams.toString()
|
const s = searchParams.toString()
|
||||||
@@ -399,7 +417,18 @@ export function ViolationsNotifiedPage() {
|
|||||||
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<label htmlFor="notified-school-year" className="form-label small mb-0">School Year</label>
|
<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>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<label htmlFor="notified-semester" className="form-label small mb-0">Semester</label>
|
<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 { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session'
|
import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session'
|
||||||
import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types'
|
import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types'
|
||||||
|
|
||||||
@@ -9,14 +10,9 @@ function adjustQty(input: HTMLInputElement, delta: number) {
|
|||||||
input.value = String(curr + delta)
|
input.value = String(curr + delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultSchoolYear() {
|
|
||||||
const y = new Date().getFullYear()
|
|
||||||
return `${y}-${y + 1}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClassPrepListPage() {
|
export function ClassPrepListPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const schoolYear = searchParams.get('school_year') ?? defaultSchoolYear()
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
const semester = searchParams.get('semester') ?? ''
|
const semester = searchParams.get('semester') ?? ''
|
||||||
|
|
||||||
const [data, setData] = useState<ClassPrepIndexResponse | null>(null)
|
const [data, setData] = useState<ClassPrepIndexResponse | null>(null)
|
||||||
@@ -24,19 +20,16 @@ export function ClassPrepListPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [savingId, setSavingId] = useState<string | null>(null)
|
const [savingId, setSavingId] = useState<string | null>(null)
|
||||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
const yearOptions = useMemo(() => {
|
preferredYear: schoolYear,
|
||||||
const out: string[] = []
|
})
|
||||||
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
|
|
||||||
return out
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
fetchClassPrepIndex({
|
fetchClassPrepIndex({
|
||||||
school_year: schoolYear,
|
school_year: schoolYear || selectedYear,
|
||||||
semester: semester || undefined,
|
semester: semester || undefined,
|
||||||
})
|
})
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
@@ -115,11 +108,11 @@ export function ClassPrepListPage() {
|
|||||||
name="school_year"
|
name="school_year"
|
||||||
id="school_year"
|
id="school_year"
|
||||||
className="form-select form-select-sm w-auto"
|
className="form-select form-select-sm w-auto"
|
||||||
defaultValue={schoolYear}
|
defaultValue={schoolYear || selectedYear}
|
||||||
>
|
>
|
||||||
{yearOptions.map((y) => (
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
||||||
<option key={y} value={y}>
|
<option key={option.value} value={option.value}>
|
||||||
{y}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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'
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
function defaultSchoolYear() {
|
|
||||||
const y = new Date().getFullYear()
|
|
||||||
return `${y}-${y + 1}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
|
export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
@@ -12,24 +8,26 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
|
|||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const schoolYear = searchParams.get('school_year') ?? ''
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
const semester = searchParams.get('semester') ?? ''
|
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 =
|
useEffect(() => {
|
||||||
schoolYears && schoolYears.length > 0
|
setYearValue(schoolYear || selectedYear)
|
||||||
? schoolYears
|
}, [schoolYear, selectedYear])
|
||||||
: (() => {
|
|
||||||
const out: string[] = []
|
useEffect(() => {
|
||||||
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
|
setSemesterValue(semester)
|
||||||
return out
|
}, [semester])
|
||||||
})()
|
|
||||||
|
|
||||||
function onSubmit(e: FormEvent<HTMLFormElement>) {
|
function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const fd = new FormData(e.currentTarget)
|
|
||||||
const next = new URLSearchParams()
|
const next = new URLSearchParams()
|
||||||
const sy = String(fd.get('school_year') ?? '')
|
if (yearValue) next.set('school_year', yearValue)
|
||||||
const sem = String(fd.get('semester') ?? '')
|
if (semesterValue) next.set('semester', semesterValue)
|
||||||
if (sy) next.set('school_year', sy)
|
|
||||||
if (sem) next.set('semester', sem)
|
|
||||||
navigate({ pathname, search: next.toString() ? `?${next}` : '' })
|
navigate({ pathname, search: next.toString() ? `?${next}` : '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +44,12 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
|
|||||||
name="school_year"
|
name="school_year"
|
||||||
className="form-select form-select-sm"
|
className="form-select form-select-sm"
|
||||||
style={{ minWidth: 180 }}
|
style={{ minWidth: 180 }}
|
||||||
defaultValue={schoolYear || years[0] || defaultSchoolYear()}
|
value={yearValue}
|
||||||
|
onChange={(event) => setYearValue(event.target.value)}
|
||||||
>
|
>
|
||||||
{years.map((y) => (
|
{options.map((option) => (
|
||||||
<option key={y} value={y}>
|
<option key={option.value} value={option.value}>
|
||||||
{y}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -63,7 +62,8 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
|
|||||||
name="semester"
|
name="semester"
|
||||||
className="form-select form-select-sm"
|
className="form-select form-select-sm"
|
||||||
style={{ minWidth: 140 }}
|
style={{ minWidth: 140 }}
|
||||||
defaultValue={semester}
|
value={semesterValue}
|
||||||
|
onChange={(event) => setSemesterValue(event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
<option value="Fall">Fall</option>
|
<option value="Fall">Fall</option>
|
||||||
@@ -77,7 +77,11 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-secondary btn-sm ms-1"
|
className="btn btn-outline-secondary btn-sm ms-1"
|
||||||
onClick={() => navigate({ pathname, search: '' })}
|
onClick={() => {
|
||||||
|
setYearValue(selectedYear)
|
||||||
|
setSemesterValue('')
|
||||||
|
navigate({ pathname, search: '' })
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
fetchEnrollmentWithdrawalPage,
|
fetchEnrollmentWithdrawalPage,
|
||||||
postEnrollmentWithdrawalAssignClass,
|
postEnrollmentWithdrawalAssignClass,
|
||||||
@@ -88,6 +89,10 @@ export function EnrollmentWithdrawalPage() {
|
|||||||
/** Pending edits: undefined means “use row value”. */
|
/** Pending edits: undefined means “use row value”. */
|
||||||
const [pendingStatus, setPendingStatus] = useState<Record<number, string>>({})
|
const [pendingStatus, setPendingStatus] = useState<Record<number, string>>({})
|
||||||
const [pendingClass, setPendingClass] = 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
|
const readOnly = missingYear || !isCurrentYear
|
||||||
|
|
||||||
@@ -282,8 +287,7 @@ export function EnrollmentWithdrawalPage() {
|
|||||||
setBulkWorking(false)
|
setBulkWorking(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const yearSelectValue =
|
const yearSelectValue = schoolYearParam || selectedYear || defaultYear
|
||||||
schoolYearParam || selectedYear || (schoolYears.length > 0 ? schoolYears[0] : '')
|
|
||||||
const semesterSelectValue = semesterParam
|
const semesterSelectValue = semesterParam
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -326,10 +330,10 @@ export function EnrollmentWithdrawalPage() {
|
|||||||
defaultValue={yearSelectValue}
|
defaultValue={yearSelectValue}
|
||||||
key={`${yearSelectValue}-${semesterSelectValue}`}
|
key={`${yearSelectValue}-${semesterSelectValue}`}
|
||||||
>
|
>
|
||||||
{schoolYears.length > 0 ? (
|
{options.length > 0 ? (
|
||||||
schoolYears.map((y) => (
|
options.map((option) => (
|
||||||
<option key={y} value={y}>
|
<option key={option.value} value={option.value}>
|
||||||
{y}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
|||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
import { fetchParticipation, postParticipation } from '../../api/grading'
|
import { fetchParticipation, postParticipation } from '../../api/grading'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { GRADING_PATH } from './gradingPaths'
|
import { GRADING_PATH } from './gradingPaths'
|
||||||
|
|
||||||
/** CI `grading/participation.php` */
|
/** CI `grading/participation.php` */
|
||||||
@@ -11,6 +12,10 @@ function normalizeParticipationScore(value: unknown): number | string | null {
|
|||||||
|
|
||||||
export function ParticipationPage() {
|
export function ParticipationPage() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
const requestedSchoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const { selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: requestedSchoolYear,
|
||||||
|
})
|
||||||
const [rows, setRows] = useState<
|
const [rows, setRows] = useState<
|
||||||
Array<{
|
Array<{
|
||||||
student_id: number
|
student_id: number
|
||||||
@@ -24,18 +29,24 @@ export function ParticipationPage() {
|
|||||||
const [title] = useState('Participation Scores')
|
const [title] = useState('Participation Scores')
|
||||||
const [sectionName, setSectionName] = useState('')
|
const [sectionName, setSectionName] = useState('')
|
||||||
const [semester, setSemester] = useState(searchParams.get('semester') ?? '')
|
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 [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [tableSearch, setTableSearch] = useState('')
|
const [tableSearch, setTableSearch] = useState('')
|
||||||
|
|
||||||
const classSectionId = searchParams.get('class_section_id') ?? ''
|
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(() => {
|
useEffect(() => {
|
||||||
let c = false
|
let c = false
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
fetchParticipation(searchParams)
|
fetchParticipation(participationSearchParams)
|
||||||
.then((p) => {
|
.then((p) => {
|
||||||
if (c) return
|
if (c) return
|
||||||
if (p && typeof p === 'object') {
|
if (p && typeof p === 'object') {
|
||||||
@@ -57,7 +68,11 @@ export function ParticipationPage() {
|
|||||||
setLocked(Boolean(o.scores_locked ?? o.scoresLocked ?? o.locked))
|
setLocked(Boolean(o.scores_locked ?? o.scoresLocked ?? o.locked))
|
||||||
setSectionName(typeof o.class_section_name === 'string' ? o.class_section_name : '')
|
setSectionName(typeof o.class_section_name === 'string' ? o.class_section_name : '')
|
||||||
setSemester(typeof o.semester === 'string' ? o.semester : (searchParams.get('semester') ?? ''))
|
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)
|
setError(null)
|
||||||
})
|
})
|
||||||
@@ -70,7 +85,7 @@ export function ParticipationPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
c = true
|
c = true
|
||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [effectiveSchoolYear, participationSearchParams, searchParams])
|
||||||
|
|
||||||
const visibleRows = useMemo(() => {
|
const visibleRows = useMemo(() => {
|
||||||
const normalized = tableSearch.toLowerCase().trim()
|
const normalized = tableSearch.toLowerCase().trim()
|
||||||
@@ -95,7 +110,7 @@ export function ParticipationPage() {
|
|||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
class_section_id: classSectionId ? Number(classSectionId) : undefined,
|
class_section_id: classSectionId ? Number(classSectionId) : undefined,
|
||||||
school_year: schoolYear || undefined,
|
school_year: effectiveSchoolYear || undefined,
|
||||||
semester: semester || undefined,
|
semester: semester || undefined,
|
||||||
scores: rows.reduce<Record<string, { score: number | string | null }>>((carry, row) => {
|
scores: rows.reduce<Record<string, { score: number | string | null }>>((carry, row) => {
|
||||||
carry[String(row.student_id)] = { score: row.score ?? '' }
|
carry[String(row.student_id)] = { score: row.score ?? '' }
|
||||||
@@ -103,7 +118,7 @@ export function ParticipationPage() {
|
|||||||
}, {}),
|
}, {}),
|
||||||
}
|
}
|
||||||
await postParticipation(body)
|
await postParticipation(body)
|
||||||
const next = await fetchParticipation(searchParams)
|
const next = await fetchParticipation(participationSearchParams)
|
||||||
if (next && typeof next === 'object') {
|
if (next && typeof next === 'object') {
|
||||||
const o = next as Record<string, unknown>
|
const o = next as Record<string, unknown>
|
||||||
const students = Array.isArray(o.students) ? o.students : []
|
const students = Array.isArray(o.students) ? o.students : []
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { fetchInventorySummary } from '../../api/inventory'
|
import { fetchInventorySummary } from '../../api/inventory'
|
||||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||||
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
|
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
|
||||||
@@ -20,6 +21,10 @@ export function InventorySummaryPage() {
|
|||||||
const [schoolYears, setSchoolYears] = useState<string[]>([])
|
const [schoolYears, setSchoolYears] = useState<string[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { options, currentYear: canonicalCurrentYear } = useSchoolYearOptions({
|
||||||
|
legacyYears: schoolYears,
|
||||||
|
preferredYear: selectedYear,
|
||||||
|
})
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -94,19 +99,19 @@ export function InventorySummaryPage() {
|
|||||||
value={
|
value={
|
||||||
selectedYear.toLowerCase() === 'all'
|
selectedYear.toLowerCase() === 'all'
|
||||||
? 'all'
|
? 'all'
|
||||||
: selectedYear === currentYear && !searchParams.get('school_year')
|
: selectedYear === (currentYear || canonicalCurrentYear) && !searchParams.get('school_year')
|
||||||
? '__current__'
|
? '__current__'
|
||||||
: selectedYear
|
: selectedYear
|
||||||
}
|
}
|
||||||
onChange={(e) => onYearFilter(e.target.value)}
|
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>
|
<option value="all">All Years</option>
|
||||||
{schoolYears
|
{options
|
||||||
.filter((y) => y !== currentYear)
|
.filter((option) => option.value !== (currentYear || canonicalCurrentYear))
|
||||||
.map((y) => (
|
.map((option) => (
|
||||||
<option key={y} value={y}>
|
<option key={option.value} value={option.value}>
|
||||||
{y}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useCallback, useEffect, useState } from 'react'
|
import { type FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { fetchTeacherBookDistribute, postTeacherBookDistribute } from '../../api/inventory'
|
import { fetchTeacherBookDistribute, postTeacherBookDistribute } from '../../api/inventory'
|
||||||
|
|
||||||
type BookGroup = { label: string; items: Record<string, unknown>[] }
|
type BookGroup = { label: string; items: Record<string, unknown>[] }
|
||||||
@@ -23,6 +24,9 @@ export function TeacherBookDistributePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -74,6 +78,8 @@ export function TeacherBookDistributePage() {
|
|||||||
setSelected((prev) => ({ ...prev, [k]: checked }))
|
setSelected((prev) => ({ ...prev, [k]: checked }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveSchoolYear = schoolYear || selectedYear
|
||||||
|
|
||||||
function checkAll(on: boolean) {
|
function checkAll(on: boolean) {
|
||||||
const next: Record<string, boolean> = {}
|
const next: Record<string, boolean> = {}
|
||||||
if (on) {
|
if (on) {
|
||||||
@@ -179,7 +185,7 @@ export function TeacherBookDistributePage() {
|
|||||||
<div className="alert alert-info py-2">
|
<div className="alert alert-info py-2">
|
||||||
<div className="d-flex justify-content-between flex-wrap gap-2">
|
<div className="d-flex justify-content-between flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<strong>School Year:</strong> {schoolYear}, <strong>Semester:</strong> {semester}
|
<strong>School Year:</strong> {effectiveSchoolYear || '—'}, <strong>Semester:</strong> {semester}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>On Hand:</strong> {onHand}
|
<strong>On Hand:</strong> {onHand}
|
||||||
@@ -239,7 +245,7 @@ export function TeacherBookDistributePage() {
|
|||||||
{String(student.semester ?? semester)}
|
{String(student.semester ?? semester)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
{String(student.school_year ?? schoolYear)}
|
{String(student.school_year ?? effectiveSchoolYear)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
{given ? (
|
{given ? (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
fetchInvoiceManagement,
|
fetchInvoiceManagement,
|
||||||
type InvoiceManagementRow,
|
type InvoiceManagementRow,
|
||||||
@@ -37,6 +38,10 @@ export function InvoiceManagementPage() {
|
|||||||
const [generating, setGenerating] = useState<number | null>(null)
|
const [generating, setGenerating] = useState<number | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [toast, setToast] = useState<{ ok: boolean; message: 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) => {
|
const load = useCallback(async (year?: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -47,7 +52,7 @@ export function InvoiceManagementPage() {
|
|||||||
const data = await fetchInvoiceManagement(sp)
|
const data = await fetchInvoiceManagement(sp)
|
||||||
const years = Array.isArray(data.schoolYears) ? data.schoolYears : []
|
const years = Array.isArray(data.schoolYears) ? data.schoolYears : []
|
||||||
setSchoolYears(years)
|
setSchoolYears(years)
|
||||||
const sel = data.schoolYear ?? years[0] ?? ''
|
const sel = data.schoolYear ?? years[0] ?? defaultYear
|
||||||
setSchoolYear(sel)
|
setSchoolYear(sel)
|
||||||
setInvoices(Array.isArray(data.invoices) ? data.invoices : [])
|
setInvoices(Array.isArray(data.invoices) ? data.invoices : [])
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -56,7 +61,7 @@ export function InvoiceManagementPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [defaultYear])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
@@ -133,9 +138,9 @@ export function InvoiceManagementPage() {
|
|||||||
value={schoolYear}
|
value={schoolYear}
|
||||||
onChange={(e) => void onYearChange(e.target.value)}
|
onChange={(e) => void onYearChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{schoolYears.map((y) => (
|
{options.map((option) => (
|
||||||
<option key={y} value={y}>
|
<option key={option.value} value={option.value}>
|
||||||
{y}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useCallback, useEffect, useState } from 'react'
|
import { type FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
fetchChargeInvoicesForParent,
|
fetchChargeInvoicesForParent,
|
||||||
fetchExtraChargesPage,
|
fetchExtraChargesPage,
|
||||||
@@ -33,6 +34,10 @@ export function ExtraChargesPage() {
|
|||||||
const [invoiceOptions, setInvoiceOptions] = useState<Array<{ id?: number; invoice_number?: string }>>([])
|
const [invoiceOptions, setInvoiceOptions] = useState<Array<{ id?: number; invoice_number?: string }>>([])
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [selectedParentId, setSelectedParentId] = useState<string>('')
|
const [selectedParentId, setSelectedParentId] = useState<string>('')
|
||||||
|
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
|
||||||
|
legacyYears: schoolYears,
|
||||||
|
preferredYear: activeYear || schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveYear(schoolYear)
|
setActiveYear(schoolYear)
|
||||||
@@ -101,15 +106,6 @@ export function ExtraChargesPage() {
|
|||||||
let pageTotal = 0
|
let pageTotal = 0
|
||||||
for (const r of rows) pageTotal += Number(r.amount ?? 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 (
|
return (
|
||||||
<div className="container-fluid py-4">
|
<div className="container-fluid py-4">
|
||||||
{msg ? <div className="alert alert-info">{msg}</div> : null}
|
{msg ? <div className="alert alert-info">{msg}</div> : null}
|
||||||
@@ -130,12 +126,12 @@ export function ExtraChargesPage() {
|
|||||||
id="schoolYearSel"
|
id="schoolYearSel"
|
||||||
className="form-select form-select-sm rounded-pill px-3"
|
className="form-select form-select-sm rounded-pill px-3"
|
||||||
style={{ minWidth: 180 }}
|
style={{ minWidth: 180 }}
|
||||||
value={activeYear || years[0] || ''}
|
value={activeYear || defaultYear}
|
||||||
onChange={onYearChange}
|
onChange={onYearChange}
|
||||||
>
|
>
|
||||||
{years.map((y) => (
|
{options.map((option) => (
|
||||||
<option key={y} value={y}>
|
<option key={option.value} value={option.value}>
|
||||||
{y}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
downloadFinancialReportCsv,
|
downloadFinancialReportCsv,
|
||||||
fetchFinancialReportDetailed,
|
fetchFinancialReportDetailed,
|
||||||
@@ -29,6 +30,10 @@ export function FinancialReportPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [lastRef, setLastRef] = useState<string | null>(null)
|
const [lastRef, setLastRef] = useState<string | null>(null)
|
||||||
|
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
|
||||||
|
legacyYears: schoolYears,
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
const hydrate = useCallback(() => {
|
const hydrate = useCallback(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -133,15 +138,6 @@ export function FinancialReportPage() {
|
|||||||
const totalCheck = Number(pt.total_check ?? 0)
|
const totalCheck = Number(pt.total_check ?? 0)
|
||||||
const grandTotal = Number(pt.total_all ?? totalCash + totalCredit + totalCheck)
|
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 (
|
return (
|
||||||
<div className="container-fluid mt-4">
|
<div className="container-fluid mt-4">
|
||||||
<h2 className="text-center mt-4 mb-3">Detailed Financial Report</h2>
|
<h2 className="text-center mt-4 mb-3">Detailed Financial Report</h2>
|
||||||
@@ -158,12 +154,12 @@ export function FinancialReportPage() {
|
|||||||
name="school_year"
|
name="school_year"
|
||||||
className="form-select form-select-sm rounded-pill px-3"
|
className="form-select form-select-sm rounded-pill px-3"
|
||||||
style={{ minWidth: 180 }}
|
style={{ minWidth: 180 }}
|
||||||
value={schoolYear || yearOptions[0] || ''}
|
value={schoolYear || defaultYear}
|
||||||
onChange={(e) => setSchoolYear(e.target.value)}
|
onChange={(e) => setSchoolYear(e.target.value)}
|
||||||
>
|
>
|
||||||
{yearOptions.map((sy) => (
|
{options.map((option) => (
|
||||||
<option key={sy} value={sy}>
|
<option key={option.value} value={option.value}>
|
||||||
{sy}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -221,7 +217,7 @@ export function FinancialReportPage() {
|
|||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
className="btn btn-info"
|
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
|
Display Summary Report
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useState } from 'react'
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
downloadFinancialSummaryPdf,
|
downloadFinancialSummaryPdf,
|
||||||
fetchFinancialReportSummary,
|
fetchFinancialReportSummary,
|
||||||
@@ -19,6 +20,9 @@ export function FinancialReportSummaryPage() {
|
|||||||
const [d, setD] = useState<FinancialSummaryJson | null>(null)
|
const [d, setD] = useState<FinancialSummaryJson | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear || urlYear,
|
||||||
|
})
|
||||||
|
|
||||||
function load(y: string) {
|
function load(y: string) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -87,7 +91,7 @@ export function FinancialReportSummaryPage() {
|
|||||||
name="school_year"
|
name="school_year"
|
||||||
className="form-select form-select-sm rounded-pill px-3"
|
className="form-select form-select-sm rounded-pill px-3"
|
||||||
style={{ minWidth: 180 }}
|
style={{ minWidth: 180 }}
|
||||||
value={schoolYear}
|
value={schoolYear || selectedYear}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const y = e.target.value
|
const y = e.target.value
|
||||||
setSchoolYear(y)
|
setSchoolYear(y)
|
||||||
@@ -96,15 +100,11 @@ export function FinancialReportSummaryPage() {
|
|||||||
setSearchParams(next)
|
setSearchParams(next)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
||||||
const years: string[] = []
|
<option key={option.value} value={option.value}>
|
||||||
for (let y = new Date().getFullYear(); y >= 2020; y--) years.push(`${y}-${y + 1}`)
|
{option.label}
|
||||||
return years.map((y) => (
|
</option>
|
||||||
<option key={y} value={y}>
|
))}
|
||||||
{y}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
})()}
|
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
<div className="d-flex align-items-center gap-2 flex-nowrap flex-shrink-0">
|
<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 { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
downloadBatchCsv,
|
downloadBatchCsv,
|
||||||
downloadReimbursementsExport,
|
downloadReimbursementsExport,
|
||||||
@@ -92,6 +93,10 @@ export function ReimbursementsIndexPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const schoolYears = data?.schoolYears ?? []
|
const schoolYears = data?.schoolYears ?? []
|
||||||
|
const { options } = useSchoolYearOptions({
|
||||||
|
legacyYears: schoolYears,
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
const users = data?.users ?? []
|
const users = data?.users ?? []
|
||||||
const batchSummaries = (data?.batchSummaries ?? []) as BatchSummaryRow[]
|
const batchSummaries = (data?.batchSummaries ?? []) as BatchSummaryRow[]
|
||||||
const batchDetails = data?.batchDetails ?? {}
|
const batchDetails = data?.batchDetails ?? {}
|
||||||
@@ -188,9 +193,9 @@ export function ReimbursementsIndexPage() {
|
|||||||
<label className="form-label">School Year</label>
|
<label className="form-label">School Year</label>
|
||||||
<select name="school_year" className="form-select" defaultValue={schoolYear}>
|
<select name="school_year" className="form-select" defaultValue={schoolYear}>
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
{schoolYears.map((sy) => (
|
{options.map((option) => (
|
||||||
<option key={sy} value={sy}>
|
<option key={option.value} value={option.value}>
|
||||||
{sy}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { fetchCombinedReport } from '../../api/reportsCombined'
|
import { fetchCombinedReport } from '../../api/reportsCombined'
|
||||||
import { REPORT_COMBINED_PATH } from './reportPaths'
|
import { REPORT_COMBINED_PATH } from './reportPaths'
|
||||||
|
|
||||||
@@ -275,6 +276,10 @@ export function CombinedReportPage() {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
userSelect: 'none' as const,
|
userSelect: 'none' as const,
|
||||||
}
|
}
|
||||||
|
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: selectedYear,
|
||||||
|
})
|
||||||
|
const schoolYearOptions = options.length > 0 ? options : [{ value: defaultYear, label: defaultYear }]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-fluid py-4">
|
<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}>
|
<form className="row g-3 mb-4 align-items-end" onSubmit={applyFilter}>
|
||||||
<div className="col-md-6 col-lg-4">
|
<div className="col-md-6 col-lg-4">
|
||||||
<label htmlFor="school_year" className="form-label">School Year</label>
|
<label htmlFor="school_year" className="form-label">School Year</label>
|
||||||
<input
|
<select id="school_year" name="school_year" className="form-select" defaultValue={selectedYear || defaultYear}>
|
||||||
id="school_year"
|
{schoolYearOptions.map((option) => (
|
||||||
type="text"
|
<option key={option.value} value={option.value}>
|
||||||
name="school_year"
|
{option.label}
|
||||||
className="form-control"
|
</option>
|
||||||
defaultValue={selectedYear}
|
))}
|
||||||
placeholder="e.g. 2025-2026"
|
</select>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-md-6 col-lg-4">
|
<div className="col-md-6 col-lg-4">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
fetchBadgeScanLogs,
|
fetchBadgeScanLogs,
|
||||||
type BadgeScanLogRow,
|
type BadgeScanLogRow,
|
||||||
} from '../../api/badgeScan'
|
} from '../../api/badgeScan'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
|
|
||||||
function formatDateTime(raw: string | null): string {
|
function formatDateTime(raw: string | null): string {
|
||||||
if (!raw) return '—'
|
if (!raw) return '—'
|
||||||
@@ -80,6 +81,10 @@ export function BadgeScanLogsPage() {
|
|||||||
() => [...new Set(logs.map((r) => r.school_year).filter(Boolean))].sort().reverse() as string[],
|
() => [...new Set(logs.map((r) => r.school_year).filter(Boolean))].sort().reverse() as string[],
|
||||||
[logs],
|
[logs],
|
||||||
)
|
)
|
||||||
|
const { options } = useSchoolYearOptions({
|
||||||
|
legacyYears: schoolYears,
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-fluid px-0">
|
<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>
|
<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}>
|
<select id="bsl-school-year" name="school_year" className="form-select form-select-sm" defaultValue={schoolYear}>
|
||||||
<option value="">— All —</option>
|
<option value="">— All —</option>
|
||||||
{schoolYears.map((sy) => (
|
{options.map((option) => (
|
||||||
<option key={sy} value={sy}>{sy}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { fetchScorePrediction, type ScorePredictionStudent } from '../../api/scoreAnalysis'
|
import { fetchScorePrediction, type ScorePredictionStudent } from '../../api/scoreAnalysis'
|
||||||
import { FailRiskLabel, StatusBadge, TrophyChanceLabel } from './scorePredictionUi'
|
import { FailRiskLabel, StatusBadge, TrophyChanceLabel } from './scorePredictionUi'
|
||||||
|
|
||||||
@@ -15,6 +16,9 @@ export function ScorePredictionPage() {
|
|||||||
|
|
||||||
const [data, setData] = useState<Awaited<ReturnType<typeof fetchScorePrediction>> | null>(null)
|
const [data, setData] = useState<Awaited<ReturnType<typeof fetchScorePrediction>> | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYearParam,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -125,6 +129,7 @@ export function ScorePredictionPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syDefault = data?.school_year ?? schoolYearParam
|
const syDefault = data?.school_year ?? schoolYearParam
|
||||||
|
const schoolYearOptions = options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]
|
||||||
|
|
||||||
const statusColors = ['#198754', '#dc3545', '#0d6efd', '#ffc107', '#6c757d']
|
const statusColors = ['#198754', '#dc3545', '#0d6efd', '#ffc107', '#6c757d']
|
||||||
const riskColors = ['#198754', '#ffc107', '#dc3545', '#6610f2', '#adb5bd']
|
const riskColors = ['#198754', '#ffc107', '#dc3545', '#6610f2', '#adb5bd']
|
||||||
@@ -148,13 +153,13 @@ export function ScorePredictionPage() {
|
|||||||
<label htmlFor="school_year" className="form-label">
|
<label htmlFor="school_year" className="form-label">
|
||||||
School Year
|
School Year
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select className="form-select" id="school_year" name="school_year" defaultValue={syDefault || selectedYear}>
|
||||||
type="text"
|
{schoolYearOptions.map((option) => (
|
||||||
className="form-control"
|
<option key={option.value} value={option.value}>
|
||||||
id="school_year"
|
{option.label}
|
||||||
name="school_year"
|
</option>
|
||||||
defaultValue={syDefault}
|
))}
|
||||||
/>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-lg-4">
|
<div className="col-md-6 col-lg-4">
|
||||||
<label htmlFor="class_section_id" className="form-label">
|
<label htmlFor="class_section_id" className="form-label">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { fetchSlipPreviewList, type SlipPreviewRow } from '../../api/slips'
|
import { fetchSlipPreviewList, type SlipPreviewRow } from '../../api/slips'
|
||||||
import { displaySemester, formatPreviewDateKey } from './slipFormatting'
|
import { displaySemester, formatPreviewDateKey } from './slipFormatting'
|
||||||
import { SLIPS_PRINT_PATH } from './slipPaths'
|
import { SLIPS_PRINT_PATH } from './slipPaths'
|
||||||
@@ -60,6 +61,9 @@ export function SlipPreviewListPage() {
|
|||||||
key: 'student_name',
|
key: 'student_name',
|
||||||
direction: 'asc',
|
direction: 'asc',
|
||||||
})
|
})
|
||||||
|
const { options, selectedYear } = useSchoolYearOptions({
|
||||||
|
preferredYear: schoolYear,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -159,13 +163,13 @@ export function SlipPreviewListPage() {
|
|||||||
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}>
|
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}>
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
<label className="form-label">School Year</label>
|
<label className="form-label">School Year</label>
|
||||||
<input
|
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
|
||||||
type="text"
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
||||||
name="school_year"
|
<option key={option.value} value={option.value}>
|
||||||
className="form-control"
|
{option.label}
|
||||||
defaultValue={schoolYear}
|
</option>
|
||||||
placeholder="e.g. 2025-2026"
|
))}
|
||||||
/>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
<label className="form-label">Semester</label>
|
<label className="form-label">Semester</label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { useAuth } from '../../auth/AuthProvider'
|
import { useAuth } from '../../auth/AuthProvider'
|
||||||
|
import { fetchSchoolYears } from '../../api/schoolYears'
|
||||||
import {
|
import {
|
||||||
fetchCompetitionScoresIndex,
|
fetchCompetitionScoresIndex,
|
||||||
fetchParentEnrollments,
|
fetchParentEnrollments,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
semesterHeaderLabel,
|
semesterHeaderLabel,
|
||||||
sortSemesterKeys,
|
sortSemesterKeys,
|
||||||
} from './scoreCardListUtils'
|
} from './scoreCardListUtils'
|
||||||
|
import { canonicalSchoolYearName } from '../../lib/schoolYearOptions'
|
||||||
|
|
||||||
const LS_CLASS = 'alrahma_teacher_score_card_class_section_id'
|
const LS_CLASS = 'alrahma_teacher_score_card_class_section_id'
|
||||||
const LS_YEAR = 'alrahma_teacher_score_card_school_year'
|
const LS_YEAR = 'alrahma_teacher_score_card_school_year'
|
||||||
@@ -41,16 +43,17 @@ async function resolveTeacherScoreCardContext(searchParams: URLSearchParams): Pr
|
|||||||
classSectionId: number
|
classSectionId: number
|
||||||
schoolYear: string | null
|
schoolYear: string | null
|
||||||
}> {
|
}> {
|
||||||
|
const canonicalSchoolYear = await resolveDefaultSchoolYear()
|
||||||
const fromUrlC = Number(searchParams.get('class_section_id') ?? '0')
|
const fromUrlC = Number(searchParams.get('class_section_id') ?? '0')
|
||||||
const fromUrlY = searchParams.get('school_year')?.trim() || null
|
const fromUrlY = searchParams.get('school_year')?.trim() || null
|
||||||
if (fromUrlC > 0) {
|
if (fromUrlC > 0) {
|
||||||
return { classSectionId: fromUrlC, schoolYear: fromUrlY }
|
return { classSectionId: fromUrlC, schoolYear: fromUrlY ?? canonicalSchoolYear }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const lsC = Number(localStorage.getItem(LS_CLASS) ?? '0')
|
const lsC = Number(localStorage.getItem(LS_CLASS) ?? '0')
|
||||||
const lsY = localStorage.getItem(LS_YEAR)?.trim() || null
|
const lsY = localStorage.getItem(LS_YEAR)?.trim() || null
|
||||||
if (lsC > 0) {
|
if (lsC > 0) {
|
||||||
return { classSectionId: lsC, schoolYear: fromUrlY ?? lsY }
|
return { classSectionId: lsC, schoolYear: fromUrlY ?? lsY ?? canonicalSchoolYear }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -63,16 +66,16 @@ async function resolveTeacherScoreCardContext(searchParams: URLSearchParams): Pr
|
|||||||
if (cid > 0) {
|
if (cid > 0) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LS_CLASS, String(cid))
|
localStorage.setItem(LS_CLASS, String(cid))
|
||||||
if (sy) localStorage.setItem(LS_YEAR, sy)
|
if (sy ?? canonicalSchoolYear) localStorage.setItem(LS_YEAR, sy ?? canonicalSchoolYear ?? '')
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
return { classSectionId: cid, schoolYear: sy }
|
return { classSectionId: cid, schoolYear: sy ?? canonicalSchoolYear }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
return { classSectionId: 0, schoolYear: fromUrlY }
|
return { classSectionId: 0, schoolYear: fromUrlY ?? canonicalSchoolYear }
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterStudentsToClassSection(
|
function filterStudentsToClassSection(
|
||||||
@@ -86,6 +89,13 @@ function filterStudentsToClassSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDefaultSchoolYear(): Promise<string | null> {
|
async function resolveDefaultSchoolYear(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const years = await fetchSchoolYears()
|
||||||
|
const canonical = canonicalSchoolYearName(years)
|
||||||
|
if (canonical) return canonical
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const comp = await fetchCompetitionScoresIndex(null)
|
const comp = await fetchCompetitionScoresIndex(null)
|
||||||
const sy = comp.schoolYear != null && String(comp.schoolYear).trim() !== '' ? String(comp.schoolYear).trim() : 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 { useEffect, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
fetchTeacherDashboard,
|
fetchTeacherDashboard,
|
||||||
isTeacherDashboardApiEnabled,
|
isTeacherDashboardApiEnabled,
|
||||||
type TeacherDashboardPayload,
|
type TeacherDashboardPayload,
|
||||||
} from '../../api/teacherSession'
|
} from '../../api/teacherSession'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import { TeacherShell } from './TeacherShell'
|
import { TeacherShell } from './TeacherShell'
|
||||||
import { useTeacherResource } from './useTeacherResource'
|
import { useTeacherResource } from './useTeacherResource'
|
||||||
|
|
||||||
@@ -18,6 +19,16 @@ type SentPayload = { sentMessages?: MsgRow[] }
|
|||||||
|
|
||||||
type TeacherMessagePayload = { message?: Record<string, unknown>; thread?: unknown[] }
|
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. */
|
/** Every `/app/teacher/*` route so nothing is hidden behind the slim top nav alone. */
|
||||||
const TEACHER_PAGE_GROUPS = [
|
const TEACHER_PAGE_GROUPS = [
|
||||||
{
|
{
|
||||||
@@ -204,14 +215,20 @@ export function TeacherNoClassesPage() {
|
|||||||
|
|
||||||
/** `teacher/select_semester.php` */
|
/** `teacher/select_semester.php` */
|
||||||
export function TeacherSelectSemesterPage() {
|
export function TeacherSelectSemesterPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const { data, loading, error } = useTeacherResource<{
|
const { data, loading, error } = useTeacherResource<{
|
||||||
invalidChoice?: string
|
invalidChoice?: string
|
||||||
fallPath?: string
|
fallPath?: string
|
||||||
springPath?: string
|
springPath?: string
|
||||||
}>('/select-semester')
|
}>('/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 fallTo = appendSchoolYearParam(data?.fallPath ?? '/app/teacher/scores?semester=Fall', activeSchoolYear)
|
||||||
const springTo = data?.springPath ?? '/app/teacher/scores?semester=Spring'
|
const springTo = appendSchoolYearParam(data?.springPath ?? '/app/teacher/scores?semester=Spring', activeSchoolYear)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TeacherShell title="Choose a semester" loading={loading} error={error}>
|
<TeacherShell title="Choose a semester" loading={loading} error={error}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTeacherResource } from './useTeacherResource'
|
|||||||
import { postTeacherMultipart } from '../../api/teacherSession'
|
import { postTeacherMultipart } from '../../api/teacherSession'
|
||||||
import { useAuth } from '../../auth/AuthProvider'
|
import { useAuth } from '../../auth/AuthProvider'
|
||||||
import { ApiHttpError, apiFetch } from '../../api/http'
|
import { ApiHttpError, apiFetch } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
|
|
||||||
function TeacherApiDumpPage({
|
function TeacherApiDumpPage({
|
||||||
title,
|
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 = {
|
type CompetitionIndexPayload = {
|
||||||
success?: string
|
success?: string
|
||||||
error?: string
|
error?: string
|
||||||
@@ -1372,6 +1387,10 @@ export function TeacherScoresPage() {
|
|||||||
const [search] = useSearchParams()
|
const [search] = useSearchParams()
|
||||||
const requestedSemester = search.get('semester')?.trim() ?? ''
|
const requestedSemester = search.get('semester')?.trim() ?? ''
|
||||||
const requestedSchoolYear = search.get('school_year')?.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 [payload, setPayload] = useState<TeacherScoresPayload | null>(null)
|
||||||
const [rows, setRows] = useState<TeacherScoresRow[]>([])
|
const [rows, setRows] = useState<TeacherScoresRow[]>([])
|
||||||
@@ -1384,7 +1403,7 @@ export function TeacherScoresPage() {
|
|||||||
const [tableSearch, setTableSearch] = useState('')
|
const [tableSearch, setTableSearch] = useState('')
|
||||||
|
|
||||||
const resolvedSemester = payload?.semester?.trim() || requestedSemester
|
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 semesterKey = teacherSemesterKey(resolvedSemester)
|
||||||
const termLabel = semesterKey === 'midterm' ? 'Midterm' : semesterKey === 'final' ? 'Final Exam' : 'Term Score'
|
const termLabel = semesterKey === 'midterm' ? 'Midterm' : semesterKey === 'final' ? 'Final Exam' : 'Term Score'
|
||||||
const termCommentLabel = semesterKey === 'midterm' ? 'Midterm Comment' : semesterKey === 'final' ? 'Final Comment' : 'Term Comment'
|
const termCommentLabel = semesterKey === 'midterm' ? 'Midterm Comment' : semesterKey === 'final' ? 'Final Comment' : 'Term Comment'
|
||||||
@@ -1464,7 +1483,7 @@ export function TeacherScoresPage() {
|
|||||||
setWarningLines([])
|
setWarningLines([])
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (requestedSemester !== '') params.set('semester', requestedSemester)
|
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 query = params.toString()
|
||||||
const body = await apiFetch<TeacherScoresPayload>(`/api/v1/teacher/scores${query ? `?${query}` : ''}`)
|
const body = await apiFetch<TeacherScoresPayload>(`/api/v1/teacher/scores${query ? `?${query}` : ''}`)
|
||||||
setPayload(body)
|
setPayload(body)
|
||||||
@@ -1478,7 +1497,7 @@ export function TeacherScoresPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [requestedSchoolYear, requestedSemester])
|
}, [effectiveRequestedSchoolYear, requestedSemester])
|
||||||
|
|
||||||
const visibleRows = useMemo(() => {
|
const visibleRows = useMemo(() => {
|
||||||
const needle = tableSearch.trim().toLowerCase()
|
const needle = tableSearch.trim().toLowerCase()
|
||||||
@@ -1674,7 +1693,12 @@ export function TeacherScoresPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="text-center mb-3">
|
<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
|
Choose another semester
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -1807,25 +1831,67 @@ export function TeacherScoresPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex flex-wrap gap-2 mt-3">
|
<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
|
Add Homework
|
||||||
</Link>
|
</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
|
Add Quiz
|
||||||
</Link>
|
</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
|
Add Project
|
||||||
</Link>
|
</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
|
Add Participation
|
||||||
</Link>
|
</Link>
|
||||||
{semesterKey === 'midterm' ? (
|
{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
|
Add Midterm
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{semesterKey === 'final' ? (
|
{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
|
Add Final
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
|
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
fetchTrophyFinal,
|
fetchTrophyFinal,
|
||||||
fetchTrophyProjection,
|
fetchTrophyProjection,
|
||||||
@@ -62,14 +63,19 @@ function TrophyFilters({
|
|||||||
selectedPercentile: number
|
selectedPercentile: number
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { options } = useSchoolYearOptions({
|
||||||
|
legacyYears: years,
|
||||||
|
preferredYear: selectedYear,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="row g-3 align-items-end mb-4" onSubmit={onSubmit}>
|
<form className="row g-3 align-items-end mb-4" onSubmit={onSubmit}>
|
||||||
<div className="col-md-4 col-lg-3">
|
<div className="col-md-4 col-lg-3">
|
||||||
<label className="form-label">School Year</label>
|
<label className="form-label">School Year</label>
|
||||||
<select className="form-select" name="school_year" defaultValue={selectedYear}>
|
<select className="form-select" name="school_year" defaultValue={selectedYear}>
|
||||||
{(years.length > 0 ? years : [selectedYear]).map((year) => (
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((year) => (
|
||||||
<option key={year} value={year}>
|
<option key={year.value} value={year.value}>
|
||||||
{year}
|
{year.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
Reference in New Issue
Block a user