add school year and security fix
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 28s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s

This commit is contained in:
root
2026-07-06 00:55:02 -04:00
parent 8cacd0874f
commit b74ddca8b1
27 changed files with 1258 additions and 39 deletions
+28
View File
@@ -73,3 +73,31 @@ body {
color: #15654f;
font-weight: 600;
}
.school-year-selector {
max-width: 100%;
}
.school-year-selector .form-select {
min-width: 11rem;
}
.school-year-selector--compact .form-label {
color: inherit;
}
.school-year-selector--compact .form-select {
width: auto;
min-width: 10rem;
}
.navbar-dark .school-year-selector,
.custom-navbar .school-year-selector {
color: inherit;
}
.navbar-dark .school-year-selector .form-label,
.custom-navbar .school-year-selector .form-label {
color: inherit;
opacity: 0.85;
}
+35
View File
@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider'
import { RequireAuth } from './auth/RequireAuth'
import { GlobalTableSorting } from './components/GlobalTableSorting'
import { SchoolYearProvider } from './context/SchoolYearContext'
import './App.css'
const PublicLayout = lazy(() =>
@@ -82,6 +83,21 @@ const SchoolYearsManagementPage = lazy(() =>
default: m.SchoolYearsManagementPage,
})),
)
const SchoolYearDetailPage = lazy(() =>
import('./pages/administrator/SchoolYearDetailPage').then((m) => ({
default: m.SchoolYearDetailPage,
})),
)
const SchoolYearClosingReportPage = lazy(() =>
import('./pages/administrator/SchoolYearReportsPage').then((m) => ({
default: () => <m.SchoolYearReportsPage reportType="closing" />,
})),
)
const SchoolYearBalanceTransfersReportPage = lazy(() =>
import('./pages/administrator/SchoolYearReportsPage').then((m) => ({
default: () => <m.SchoolYearReportsPage reportType="balance-transfers" />,
})),
)
const NavBuilderPage = lazy(() =>
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
)
@@ -973,6 +989,11 @@ const ParentPaymentInvoicesPage = lazy(() =>
default: m.ParentPaymentInvoicesPage,
})),
)
const ParentStatementPage = lazy(() =>
import('./pages/parent/ParentStatementPage').then((m) => ({
default: m.ParentStatementPage,
})),
)
const ParentRegisterStudentPage = lazy(() =>
import('./pages/parent/WiredParentScreens').then((m) => ({
default: m.ParentRegisterStudentPage,
@@ -992,6 +1013,7 @@ export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<SchoolYearProvider>
<GlobalTableSorting />
<Suspense fallback={<RouteFallback />}>
<Routes>
@@ -1132,6 +1154,17 @@ export default function App() {
<Route path="administrator/configuration_view" element={<ConfigurationViewPage />} />
<Route path="administrator/configuration" element={<Navigate to="configuration_view" replace />} />
<Route path="administrator/school-years" element={<SchoolYearsManagementPage />} />
<Route path="admin/school-years" element={<SchoolYearsManagementPage />} />
<Route path="admin/school-years/close" element={<SchoolYearsManagementPage />} />
<Route path="admin/school-years/:yearId/summary" element={<SchoolYearDetailPage />} />
<Route
path="admin/school-years/:yearId/reports/closing"
element={<SchoolYearClosingReportPage />}
/>
<Route
path="admin/school-years/:yearId/reports/balance-transfers"
element={<SchoolYearBalanceTransfersReportPage />}
/>
<Route path="administrator/discounts" element={<DiscountListPage />} />
<Route path="administrator/discounts/create" element={<DiscountCreatePage />} />
<Route path="administrator/discounts/apply" element={<DiscountApplyVoucherPage />} />
@@ -1481,6 +1514,7 @@ export default function App() {
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
<Route path="parent/events" element={<ParentEventsPage />} />
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
<Route path="parent/statements" element={<ParentStatementPage />} />
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
@@ -1513,6 +1547,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</SchoolYearProvider>
</BrowserRouter>
</AuthProvider>
)
+2 -1
View File
@@ -1,5 +1,5 @@
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
import type { ApiEnvelope } from './types'
export type CertificateDecisionRow = {
@@ -74,6 +74,7 @@ function authHeaders(accept: string) {
const headers = new Headers({ Accept: accept })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
return headers
}
+13
View File
@@ -1,4 +1,5 @@
import { apiUrl } from '../lib/apiOrigin'
import { selectedSchoolYearHeaders } from '../lib/schoolYearSelection'
const TOKEN_KEY = 'alrahma_api_token'
@@ -30,6 +31,14 @@ export function decodeJwtPayload<T = unknown>(token: string): T | null {
}
}
export function applyStoredSchoolYearHeaders(headers: Headers): Headers {
for (const [key, value] of Object.entries(selectedSchoolYearHeaders())) {
if (!headers.has(key)) headers.set(key, value)
}
return headers
}
export class ApiHttpError extends Error {
readonly status: number
readonly body: unknown
@@ -60,6 +69,10 @@ export async function apiFetch<T>(
headers.set('Authorization', `Bearer ${token}`)
}
if (attachAuth && token) {
applyStoredSchoolYearHeaders(headers)
}
const url = apiUrl(path)
let res: Response
+6 -7
View File
@@ -2,7 +2,7 @@
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
*/
import { apiUrl } from '../lib/apiOrigin'
import { apiFetch, getStoredToken } from './http'
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
const BASE = '/api/v1/finance/invoices'
@@ -50,12 +50,11 @@ export async function fetchInvoicePreview(invoiceId: string | number): Promise<u
/** Binary PDF with JWT (for tabs that cannot send Authorization). */
export async function fetchInvoicePdfBlob(invoiceId: string | number): Promise<Blob> {
const token = getStoredToken()
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/pdf',
},
})
const headers = new Headers({ Accept: 'application/pdf' })
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), { headers })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `PDF HTTP ${res.status}`)
+5 -1
View File
@@ -3,7 +3,7 @@
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type AdminNotificationRow = {
id?: number
@@ -56,6 +56,7 @@ async function postMultipart<T>(path: string, formData: FormData): Promise<T> {
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
@@ -136,6 +137,7 @@ export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string
const headers = new Headers({ Accept: '*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(apiPath), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -335,6 +337,7 @@ export async function downloadFinancialReportCsv(params: {
const headers = new Headers()
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -408,6 +411,7 @@ export async function downloadFinancialSummaryPdf(params: { school_year?: string
const headers = new Headers()
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const raw = await res.blob()
+3 -1
View File
@@ -2,7 +2,7 @@
* Teacher / admin print copy requests (legacy CI: print-requests/*).
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type PrintRequestRow = Record<string, unknown>
@@ -23,6 +23,7 @@ async function multipartRequest<T>(path: string, formData: FormData, method = 'P
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method, headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
@@ -83,6 +84,7 @@ export async function openPrintRequestFile(requestId: number | string): Promise<
const token = getStoredToken()
const headers = new Headers({ Accept: '*/*' })
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(printRequestFileUrl(requestId)), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
+6 -1
View File
@@ -2,7 +2,7 @@
* Stickers, badges, report cards (legacy CI printables_reports/*).
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type ClassOption = { class_section_id?: number; id?: number; class_section_name?: string }
@@ -40,6 +40,7 @@ export async function postStickerGenerate(formData: FormData): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl('/api/v1/administrator/printables/stickers/generate'), {
method: 'POST',
headers,
@@ -61,6 +62,7 @@ export async function postBadgeGenerate(formData: FormData): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl('/api/v1/badges/pdf'), {
method: 'POST',
headers,
@@ -242,6 +244,7 @@ export async function fetchReportCardStudentHtml(
const headers = new Headers({ Accept: 'text/html' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const path = `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
@@ -260,6 +263,7 @@ export async function fetchReportCardClassHtml(
const headers = new Headers({ Accept: 'text/html' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const path = `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
@@ -271,6 +275,7 @@ export async function openReportCardDownload(url: string): Promise<void> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const raw = await res.blob()
+6 -1
View File
@@ -3,7 +3,7 @@
* Backend is expected under `/api/v1/administrator/reimbursements`.
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
const BASE = '/api/v1/administrator/reimbursements'
@@ -58,6 +58,7 @@ export async function downloadReimbursementsExport(params?: {
const headers = new Headers({ Accept: 'text/csv,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -70,6 +71,7 @@ export async function downloadBatchCsv(batchId: number | string): Promise<void>
const headers = new Headers({ Accept: 'text/csv,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -80,6 +82,7 @@ export async function postBatchEmail(formData: FormData): Promise<{ success?: bo
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`${BASE}/batch/send`), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
@@ -118,6 +121,7 @@ async function postFormExpectJson(path: string, form: FormData): Promise<{ succe
const headers = new Headers({ Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: form })
const body: unknown = await res.json().catch(() => ({}))
const data = body as Record<string, unknown>
@@ -165,6 +169,7 @@ async function postMultipart(path: string, formData: FormData): Promise<{ ok?: b
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
+132
View File
@@ -9,6 +9,7 @@ export type SchoolYearRecord = {
end_date: string
status: SchoolYearStatus
is_current: boolean
isCurrent?: boolean
closed_at?: string | null
closed_by?: number | null
}
@@ -95,6 +96,65 @@ export type SchoolYearCloseResult = {
balance_counts?: Record<string, number>
}
export type SchoolYearClosingReport = {
old_school_year?: SchoolYearRecord | null
new_school_year?: SchoolYearRecord | null
closed_by?: string | number | null
closed_at?: string | null
audit_reference?: string | null
totals?: {
students_promoted?: number
students_repeated?: number
students_graduated?: number
students_transferred?: number
students_withdrawn?: number
parents_with_unpaid_balances?: number
total_unpaid_balance_transferred?: number
parents_with_credit_balances?: number
total_credit_transferred?: number
}
warnings?: string[]
}
export type SchoolYearBalanceTransferReportRow = {
parent_id: number
parent_name?: string | null
old_school_year?: string | null
new_school_year?: string | null
source_invoice_ids?: Array<number | string>
old_unpaid_amount?: number
new_old_balance_invoice_id?: number | string | null
transfer_status?: string | null
transfer_date?: string | null
created_by?: string | number | null
}
export type SchoolYearBalanceTransferReport = {
school_year?: SchoolYearRecord | null
rows: SchoolYearBalanceTransferReportRow[]
summary?: {
parent_count?: number
total_transferred?: number
total_credit?: number
}
}
export type ParentStatementLine = {
label: string
amount: number
source_school_year?: string | null
invoice_id?: number | string | null
}
export type ParentStatement = {
parent_id?: number | null
parent_name?: string | null
school_year?: SchoolYearRecord | string | null
lines: ParentStatementLine[]
opening_balance?: number
current_balance?: number
}
export type SaveSchoolYearPayload = {
name: string
start_date: string
@@ -103,6 +163,15 @@ export type SaveSchoolYearPayload = {
export type CloseSchoolYearPayload = {
new_school_year: SaveSchoolYearPayload
promotion_rules?: {
default_action?: string
exceptions?: Array<{
student_id: number
action: string
target_grade_id?: number | null
target_class_section_id?: number | null
}>
}
transfer_unpaid_balances?: boolean
confirmation?: string
}
@@ -147,6 +216,21 @@ type ApiCloseResponse = {
data?: SchoolYearCloseResult
}
type ApiClosingReportResponse = {
ok?: boolean
data?: SchoolYearClosingReport
}
type ApiBalanceTransferReportResponse = {
ok?: boolean
data?: SchoolYearBalanceTransferReport
}
type ApiParentStatementResponse = {
ok?: boolean
data?: ParentStatement
}
export async function fetchSchoolYears(): Promise<SchoolYearRecord[]> {
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
return response.data ?? []
@@ -292,3 +376,51 @@ export async function fetchSchoolYearPromotionPreview(
return response.data
}
export async function fetchSchoolYearClosingReport(
schoolYearId: number,
): Promise<SchoolYearClosingReport> {
const response = await apiFetch<ApiClosingReportResponse>(
`/api/v1/school-years/${schoolYearId}/reports/closing`,
)
if (!response.data) {
throw new Error('School year closing report returned no payload.')
}
return response.data
}
export async function fetchSchoolYearBalanceTransferReport(
schoolYearId: number,
): Promise<SchoolYearBalanceTransferReport> {
const response = await apiFetch<ApiBalanceTransferReportResponse>(
`/api/v1/school-years/${schoolYearId}/parent-balances`,
)
if (!response.data) {
throw new Error('School year balance transfer report returned no payload.')
}
return response.data
}
export async function fetchParentStatement(params: {
schoolYearId?: number | null
parentId?: number | null
}): Promise<ParentStatement> {
const query = new URLSearchParams()
if (params.schoolYearId != null) query.set('school_year_id', String(params.schoolYearId))
if (params.parentId != null) query.set('parent_id', String(params.parentId))
const response = await apiFetch<ApiParentStatementResponse>(
`/api/v1/parent/statements${query.toString() ? `?${query}` : ''}`,
)
if (!response.data) {
throw new Error('Parent statement returned no payload.')
}
return response.data
}
+6 -1
View File
@@ -1,5 +1,5 @@
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
import type {
AdministratorAbsenceFormResponse,
AdministratorAbsenceSubmitResponse,
@@ -595,6 +595,7 @@ export async function uploadEarlyDismissalSignature(formData: FormData): Promise
const token = getStoredToken()
const headers = new Headers({ Accept: 'application/json' })
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl('/api/v1/attendance/early-dismissals/signature'), {
method: 'POST',
body: formData,
@@ -1224,6 +1225,7 @@ export async function openProtectedApiFile(path: string): Promise<void> {
const headers = new Headers()
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) {
@@ -1255,6 +1257,7 @@ async function fetchMultipartJson<T>(
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), {
method: init.method ?? 'POST',
headers,
@@ -1788,6 +1791,7 @@ export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promi
const headers = new Headers({ Accept: 'text/csv' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const query = new URLSearchParams()
if (params?.q) query.set('q', params.q)
const suffix = query.size > 0 ? `?${query.toString()}` : ''
@@ -2142,6 +2146,7 @@ export async function fetchReportCardPdf(studentId: number): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`/api/v1/reports/report-cards/students/${studentId}`), {
headers,
})
+2 -1
View File
@@ -2,7 +2,7 @@
* Teacher portal API (`Views/teacher/*` parity).
* Laravel should expose matching routes under `/api/v1/teacher/...`.
*/
import { apiFetch, getStoredToken } from './http'
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
import { apiUrl } from '../lib/apiOrigin'
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
@@ -47,6 +47,7 @@ export async function postTeacherMultipart<T>(path: string, formData: FormData):
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
+1
View File
@@ -2,6 +2,7 @@ export type AuthUser = {
id: number
name: string
roles: Record<string, boolean>
permissions?: Record<string, boolean> | string[]
class_section_id?: number | null
class_section_name?: string | null
}
@@ -0,0 +1,23 @@
import { useSchoolYear } from '../../context/SchoolYearContext'
export function ReadOnlyBanner({ className = '' }: { className?: string }) {
const { selectedSchoolYear, isReadOnlyYear } = useSchoolYear()
if (!selectedSchoolYear || !isReadOnlyYear) return null
return (
<div className={`alert alert-warning d-flex align-items-start gap-2 mb-3 ${className}`.trim()} role="status">
<i className="bi bi-lock-fill mt-1" aria-hidden />
<div>
<div className="fw-semibold">
Viewing archived school year: {selectedSchoolYear.name}
</div>
<div className="small">
Read-only mode. Add, edit, delete, import, attendance, grading, invoice, payment,
promotion, assignment, upload, and year-bound communication actions should remain disabled.
The API still has to reject forbidden writes, because buttons are not security.
</div>
</div>
</div>
)
}
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function ReadOnlyGuard({
children,
fallback,
}: {
children: ReactNode
fallback?: ReactNode
}) {
const { isReadOnlyYear, selectedSchoolYear } = useSchoolYear()
if (!isReadOnlyYear) return <>{children}</>
return (
<div className="border rounded p-3 bg-light text-muted">
{fallback ?? `This action is disabled because ${selectedSchoolYear?.name ?? 'the selected year'} is read-only.`}
</div>
)
}
@@ -0,0 +1,53 @@
import { useId } from 'react'
import { useSchoolYear } from '../../context/SchoolYearContext'
import { isCurrentSchoolYear } from '../../lib/schoolYearSelection'
import { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
export function SchoolYearSelector({ compact = false }: { compact?: boolean }) {
const {
schoolYears,
selectedSchoolYear,
isLoadingSchoolYears,
schoolYearError,
setSelectedSchoolYearId,
} = useSchoolYear()
const selectId = useId()
if (schoolYears.length === 0 && !isLoadingSchoolYears) {
return schoolYearError ? (
<span className="badge text-bg-warning" title={schoolYearError}>
School years unavailable
</span>
) : null
}
return (
<div className={`school-year-selector d-flex align-items-center gap-2 ${compact ? 'school-year-selector--compact' : ''}`}>
<label className="form-label mb-0 small text-nowrap" htmlFor={selectId}>
School Year
</label>
<select
id={selectId}
className="form-select form-select-sm"
value={selectedSchoolYear?.id ?? ''}
disabled={isLoadingSchoolYears || schoolYears.length === 0}
onChange={(event) => {
const id = Number(event.target.value)
setSelectedSchoolYearId(Number.isFinite(id) && id > 0 ? id : null)
}}
>
{isLoadingSchoolYears && schoolYears.length === 0 ? (
<option value="">Loading</option>
) : null}
{schoolYears.map((year) => (
<option key={year.id} value={year.id}>
{year.name}
{isCurrentSchoolYear(year) ? ' Current' : ''}
{year.status && !isCurrentSchoolYear(year) ? ` ${year.status}` : ''}
</option>
))}
</select>
{selectedSchoolYear ? <SchoolYearStatusBadge status={selectedSchoolYear.status} /> : null}
</div>
)
}
@@ -0,0 +1,31 @@
import type { SchoolYearRecord } from '../../api/schoolYears'
function statusBadgeVariant(status: string | null | undefined): string {
switch (String(status ?? '').toLowerCase()) {
case 'active':
return 'success'
case 'closed':
return 'danger'
case 'archived':
return 'dark'
case 'draft':
return 'warning'
default:
return 'secondary'
}
}
export function SchoolYearStatusBadge({
status,
className = '',
}: {
status: SchoolYearRecord['status'] | null | undefined
className?: string
}) {
const label = String(status ?? 'unknown').trim() || 'unknown'
return (
<span className={`badge text-bg-${statusBadgeVariant(status)} ${className}`.trim()}>
{label.toUpperCase()}
</span>
)
}
+4
View File
@@ -0,0 +1,4 @@
export { ReadOnlyBanner } from './ReadOnlyBanner'
export { ReadOnlyGuard } from './ReadOnlyGuard'
export { SchoolYearSelector } from './SchoolYearSelector'
export { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
+180
View File
@@ -0,0 +1,180 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { useSearchParams } from 'react-router-dom'
import { fetchSchoolYears, type SchoolYearRecord } from '../api/schoolYears'
import { useAuth } from '../auth/AuthProvider'
import {
clearSelectedSchoolYear,
getStoredSelectedSchoolYearId,
getStoredSelectedSchoolYearName,
isCurrentSchoolYear,
isReadOnlySchoolYear,
storeSelectedSchoolYear,
} from '../lib/schoolYearSelection'
type SchoolYearContextValue = {
schoolYears: SchoolYearRecord[]
selectedSchoolYear: SchoolYearRecord | null
selectedSchoolYearId: number | null
selectedSchoolYearName: string | null
activeSchoolYear: SchoolYearRecord | null
isLoadingSchoolYears: boolean
schoolYearError: string | null
isReadOnlyYear: boolean
setSelectedSchoolYearId: (id: number | null) => void
refreshSchoolYears: () => Promise<void>
}
const SchoolYearContext = createContext<SchoolYearContextValue | null>(null)
function yearIdFromSearchParams(searchParams: URLSearchParams): number | null {
const raw = searchParams.get('school_year_id') ?? searchParams.get('year') ?? ''
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : null
}
function yearNameFromSearchParams(searchParams: URLSearchParams): string | null {
return (
searchParams.get('school_year')?.trim() ||
searchParams.get('schoolYear')?.trim() ||
null
)
}
function resolveSelectedYear(params: {
years: SchoolYearRecord[]
searchParams: URLSearchParams
requestedId: number | null
}): SchoolYearRecord | null {
const { years, searchParams, requestedId } = params
if (years.length === 0) return null
const urlId = yearIdFromSearchParams(searchParams)
const storedId = getStoredSelectedSchoolYearId()
const urlName = yearNameFromSearchParams(searchParams)
const storedName = getStoredSelectedSchoolYearName()
const byRequestedId = requestedId != null ? years.find((year) => year.id === requestedId) : null
if (byRequestedId) return byRequestedId
const byUrlId = urlId != null ? years.find((year) => year.id === urlId) : null
if (byUrlId) return byUrlId
const byStoredId = storedId != null ? years.find((year) => year.id === storedId) : null
if (byStoredId) return byStoredId
const byUrlName = urlName ? years.find((year) => year.name === urlName) : null
if (byUrlName) return byUrlName
const byStoredName = storedName ? years.find((year) => year.name === storedName) : null
if (byStoredName) return byStoredName
return years.find(isCurrentSchoolYear) ?? years[0] ?? null
}
export function SchoolYearProvider({ children }: { children: ReactNode }) {
const { token } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const [schoolYears, setSchoolYears] = useState<SchoolYearRecord[]>([])
const [requestedYearId, setRequestedYearId] = useState<number | null>(null)
const [isLoadingSchoolYears, setIsLoadingSchoolYears] = useState(false)
const [schoolYearError, setSchoolYearError] = useState<string | null>(null)
const refreshSchoolYears = useCallback(async () => {
if (!token) {
setSchoolYears([])
setSchoolYearError(null)
clearSelectedSchoolYear()
return
}
setIsLoadingSchoolYears(true)
setSchoolYearError(null)
try {
const years = await fetchSchoolYears()
setSchoolYears(years)
} catch (error) {
setSchoolYears([])
setSchoolYearError(error instanceof Error ? error.message : 'Unable to load school years.')
} finally {
setIsLoadingSchoolYears(false)
}
}, [token])
useEffect(() => {
void refreshSchoolYears()
}, [refreshSchoolYears])
const selectedSchoolYear = useMemo(
() =>
resolveSelectedYear({
years: schoolYears,
searchParams,
requestedId: requestedYearId,
}),
[requestedYearId, schoolYears, searchParams],
)
const activeSchoolYear = useMemo(
() => schoolYears.find(isCurrentSchoolYear) ?? null,
[schoolYears],
)
useEffect(() => {
storeSelectedSchoolYear(selectedSchoolYear)
if (!selectedSchoolYear || !token) return
setSearchParams((current) => {
const next = new URLSearchParams(current)
const currentId = next.get('school_year_id')
const currentName = next.get('school_year')
if (currentId === String(selectedSchoolYear.id) && currentName === selectedSchoolYear.name) {
return current
}
next.set('school_year_id', String(selectedSchoolYear.id))
next.set('school_year', selectedSchoolYear.name)
return next
}, { replace: true })
}, [selectedSchoolYear, setSearchParams, token])
const value = useMemo<SchoolYearContextValue>(() => ({
schoolYears,
selectedSchoolYear,
selectedSchoolYearId: selectedSchoolYear?.id ?? null,
selectedSchoolYearName: selectedSchoolYear?.name ?? null,
activeSchoolYear,
isLoadingSchoolYears,
schoolYearError,
isReadOnlyYear: isReadOnlySchoolYear(selectedSchoolYear),
setSelectedSchoolYearId: setRequestedYearId,
refreshSchoolYears,
}), [
activeSchoolYear,
isLoadingSchoolYears,
refreshSchoolYears,
schoolYearError,
schoolYears,
selectedSchoolYear,
])
return <SchoolYearContext.Provider value={value}>{children}</SchoolYearContext.Provider>
}
export function useSchoolYear(): SchoolYearContextValue {
const context = useContext(SchoolYearContext)
if (!context) {
throw new Error('useSchoolYear must be used inside SchoolYearProvider.')
}
return context
}
+8
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthProvider'
import { CiPublicFooter } from '../components/CiPartials'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
@@ -13,6 +14,7 @@ const parentNavItems = [
{ to: '/app/parent/enroll-classes', icon: 'bi bi-pencil-square', label: 'Enroll in Classes' },
{ to: '/app/parent/events', icon: 'bi bi-bell', label: 'Events' },
{ to: '/app/parent/payment', icon: 'bi bi-credit-card', label: 'Invoice' },
{ to: '/app/parent/statements', icon: 'bi bi-receipt', label: 'Statements' },
{ to: '/app/parent/register-student', icon: 'bi bi-person-plus', label: 'Register Students' },
{ to: '/app/parent/report-attendance', icon: 'bi bi-megaphone', label: 'Report Absence' },
{ to: '/app/parent/report-cards', icon: 'bi bi-file-earmark-text', label: 'Report Cards' },
@@ -214,6 +216,11 @@ export function MainLayout() {
))}
</ul>
</nav>
{user ? (
<div className="d-none d-lg-flex align-items-center me-2">
<SchoolYearSelector compact />
</div>
) : null}
{user ? (
<div
ref={clusterRef}
@@ -333,6 +340,7 @@ export function MainLayout() {
</header>
<main className="container mt-4 flex-grow-1">
<ReadOnlyBanner />
<Outlet />
</main>
+8
View File
@@ -4,6 +4,7 @@ import { ApiHttpError } from '../api/http'
import { fetchNavMenu } from '../api/session'
import type { NavItem } from '../api/types'
import { useAuth } from '../auth/AuthProvider'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import {
isExternalNavUrl,
spaPathFromCiUrl,
@@ -230,6 +231,11 @@ export function ManagementLayout() {
const navTree = useMemo(() => normalizeNavItems(items), [items])
// Auto-hide sidebar after navigating to a subject
useEffect(() => {
setSidebarOpen(false)
}, [location.pathname])
return (
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
@@ -238,6 +244,7 @@ export function ManagementLayout() {
Al Rahma Sunday School
</Link>
<div className="d-flex align-items-center gap-2">
<SchoolYearSelector compact />
<span className="navbar-text small mb-0">{user?.name}</span>
<button
type="button"
@@ -309,6 +316,7 @@ export function ManagementLayout() {
</aside>
<main className="content management-main">
<ReadOnlyBanner />
<Outlet />
</main>
</div>
+1 -1
View File
@@ -66,7 +66,7 @@ function makeOption(
}
function optionFromRecord(record: SchoolYearRecord): SchoolYearOption | null {
return makeOption(record.name, record.status, Boolean(record.is_current), record.id)
return makeOption(record.name, record.status, Boolean(record.is_current || record.isCurrent), record.id)
}
function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null {
+108
View File
@@ -0,0 +1,108 @@
import type { SchoolYearRecord } from '../api/schoolYears'
export const SELECTED_SCHOOL_YEAR_ID_STORAGE_KEY = 'alrahma_selected_school_year_id'
export const SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY = 'alrahma_selected_school_year_name'
export const SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY = 'alrahma_selected_school_year_status'
function safeLocalStorage(): Storage | null {
try {
return typeof window !== 'undefined' ? window.localStorage : null
} catch {
return null
}
}
export function isCurrentSchoolYear(year: Pick<SchoolYearRecord, 'is_current'> & { isCurrent?: boolean }): boolean {
return Boolean(year.is_current || year.isCurrent)
}
export function isReadOnlySchoolYearStatus(status: string | null | undefined): boolean {
const normalized = String(status ?? '').trim().toLowerCase()
return normalized === 'closed' || normalized === 'archived'
}
export function isReadOnlySchoolYear(year: SchoolYearRecord | null | undefined): boolean {
return year ? isReadOnlySchoolYearStatus(year.status) : false
}
export function getStoredSelectedSchoolYearId(): number | null {
const storage = safeLocalStorage()
const raw = storage?.getItem(SELECTED_SCHOOL_YEAR_ID_STORAGE_KEY) ?? ''
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : null
}
export function getStoredSelectedSchoolYearName(): string | null {
const storage = safeLocalStorage()
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)?.trim() ?? ''
return value || null
}
export function getStoredSelectedSchoolYearStatus(): string | null {
const storage = safeLocalStorage()
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)?.trim() ?? ''
return value || null
}
export function storeSelectedSchoolYear(year: SchoolYearRecord | null): void {
const storage = safeLocalStorage()
if (!storage) return
if (!year) {
storage.removeItem(SELECTED_SCHOOL_YEAR_ID_STORAGE_KEY)
storage.removeItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)
storage.removeItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)
return
}
storage.setItem(SELECTED_SCHOOL_YEAR_ID_STORAGE_KEY, String(year.id))
storage.setItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY, year.name)
storage.setItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY, year.status)
}
export function clearSelectedSchoolYear(): void {
storeSelectedSchoolYear(null)
}
export function selectedSchoolYearHeaders(): Record<string, string> {
const id = getStoredSelectedSchoolYearId()
const name = getStoredSelectedSchoolYearName()
const status = getStoredSelectedSchoolYearStatus()
const headers: Record<string, string> = {}
if (id != null) {
headers['X-School-Year-Id'] = String(id)
headers['X-Selected-School-Year-Id'] = String(id)
}
if (name) {
headers['X-School-Year'] = name
headers['X-Selected-School-Year'] = name
}
if (status) {
headers['X-School-Year-Status'] = status
}
return headers
}
export function buildSchoolYearQuery(params: {
schoolYearId?: number | null
schoolYearName?: string | null
existing?: string | URLSearchParams
}): string {
const query = new URLSearchParams(params.existing)
if (params.schoolYearId != null) {
query.set('school_year_id', String(params.schoolYearId))
}
const name = params.schoolYearName?.trim()
if (name) {
query.set('school_year', name)
}
const value = query.toString()
return value ? `?${value}` : ''
}
@@ -0,0 +1,178 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchSchoolYearParentBalances,
fetchSchoolYearPromotionPreview,
fetchSchoolYearSummary,
type SchoolYearParentBalancePreview,
type SchoolYearPromotionPreview,
type SchoolYearSummary,
} from '../../api/schoolYears'
import { SchoolYearStatusBadge } from '../../components/schoolYear'
function normalizeApiError(error: unknown, fallback: string): string {
if (error instanceof ApiHttpError) return error.message || fallback
if (error instanceof Error) return error.message
return fallback
}
function formatMoney(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
export function SchoolYearDetailPage() {
const { yearId } = useParams()
const id = Number(yearId)
const [summary, setSummary] = useState<SchoolYearSummary | null>(null)
const [promotionPreview, setPromotionPreview] = useState<SchoolYearPromotionPreview | null>(null)
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const validId = Number.isFinite(id) && id > 0
useEffect(() => {
if (!validId) {
setError('Invalid school year id.')
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
;(async () => {
try {
const [nextSummary, nextPromotion, nextBalances] = await Promise.all([
fetchSchoolYearSummary(id),
fetchSchoolYearPromotionPreview(id),
fetchSchoolYearParentBalances(id),
])
if (!cancelled) {
setSummary(nextSummary)
setPromotionPreview(nextPromotion)
setParentBalances(nextBalances)
}
} catch (loadError) {
if (!cancelled) setError(normalizeApiError(loadError, 'Unable to load school year detail.'))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [id, validId])
const totals = summary?.totals
const promotion = promotionPreview?.summary
const balances = parentBalances?.summary
const rows = useMemo(() => parentBalances?.rows.slice(0, 25) ?? [], [parentBalances])
return (
<div className="container-fluid py-4">
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
<li className="breadcrumb-item"><Link to="/app/home">Home</Link></li>
<li className="breadcrumb-item"><Link to="/app/admin/school-years">School years</Link></li>
<li className="breadcrumb-item active" aria-current="page">Summary</li>
</ol>
</nav>
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 className="h3 mb-1">School Year Summary</h1>
<p className="text-muted mb-0">
Academic promotion, parent-level balance exposure, and closure readiness in one place.
</p>
</div>
{summary?.school_year ? <SchoolYearStatusBadge status={summary.school_year.status} /> : null}
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading school year summary</p> : null}
{summary ? (
<>
<div className="row g-3 mb-4">
<div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Students considered</div>
<div className="display-6">{totals?.students_considered ?? 0}</div>
</div></div>
</div>
<div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Students blocked</div>
<div className="display-6">{totals?.students_blocked ?? 0}</div>
</div></div>
</div>
<div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Parents with balances</div>
<div className="display-6">{totals?.parents_with_balances ?? 0}</div>
</div></div>
</div>
<div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Net transfer impact</div>
<div className="display-6">${formatMoney(totals?.net_balance_to_transfer)}</div>
</div></div>
</div>
</div>
<div className="row g-4">
<div className="col-xl-5">
<div className="card shadow-sm h-100">
<div className="card-body">
<h2 className="h5">Promotion summary</h2>
<dl className="row mb-0 small">
<dt className="col-6">Promote</dt><dd className="col-6 text-end">{promotion?.promote ?? 0}</dd>
<dt className="col-6">Repeat</dt><dd className="col-6 text-end">{promotion?.repeat ?? 0}</dd>
<dt className="col-6">Graduate</dt><dd className="col-6 text-end">{promotion?.graduate ?? 0}</dd>
<dt className="col-6">Withdraw</dt><dd className="col-6 text-end">{promotion?.withdraw ?? 0}</dd>
<dt className="col-6">Missing decision</dt><dd className="col-6 text-end">{promotion?.hold ?? 0}</dd>
</dl>
</div>
</div>
</div>
<div className="col-xl-7">
<div className="card shadow-sm h-100">
<div className="card-body">
<h2 className="h5">Parent balance preview</h2>
<div className="small text-muted mb-3">
{balances?.parents_with_non_zero_balance ?? 0} parents with non-zero balances,
net ${formatMoney(balances?.net_balance_to_transfer)}.
</div>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead><tr><th>Parent</th><th>Students</th><th className="text-end">Transfer</th></tr></thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={3} className="text-muted">No balance rows found.</td></tr>
) : rows.map((row) => (
<tr key={row.parent_id}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td>
<td className="text-end">${formatMoney(row.amount_to_transfer)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</>
) : null}
</div>
)
}
@@ -0,0 +1,203 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchSchoolYearBalanceTransferReport,
fetchSchoolYearClosingReport,
type SchoolYearBalanceTransferReport,
type SchoolYearClosingReport,
} from '../../api/schoolYears'
type ReportType = 'closing' | 'balance-transfers'
function normalizeApiError(error: unknown, fallback: string): string {
if (error instanceof ApiHttpError) return error.message || fallback
if (error instanceof Error) return error.message
return fallback
}
function formatMoney(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString()
}
function ClosingReportView({ report }: { report: SchoolYearClosingReport }) {
const totals = report.totals ?? {}
return (
<div className="card shadow-sm">
<div className="card-body">
<h2 className="h5 mb-3">Closing Report</h2>
<dl className="row small mb-4">
<dt className="col-md-3">Old school year</dt>
<dd className="col-md-9">{report.old_school_year?.name ?? '—'}</dd>
<dt className="col-md-3">New school year</dt>
<dd className="col-md-9">{report.new_school_year?.name ?? '—'}</dd>
<dt className="col-md-3">Closed by</dt>
<dd className="col-md-9">{report.closed_by ?? '—'}</dd>
<dt className="col-md-3">Closed at</dt>
<dd className="col-md-9">{formatDateTime(report.closed_at)}</dd>
<dt className="col-md-3">Audit reference</dt>
<dd className="col-md-9">{report.audit_reference ?? '—'}</dd>
</dl>
<div className="row g-3 mb-4">
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Promoted</div><div className="h4 mb-0">{totals.students_promoted ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Repeated</div><div className="h4 mb-0">{totals.students_repeated ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Graduated</div><div className="h4 mb-0">{totals.students_graduated ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Parents with unpaid balances</div><div className="h4 mb-0">{totals.parents_with_unpaid_balances ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Unpaid transferred</div><div className="h4 mb-0">${formatMoney(totals.total_unpaid_balance_transferred)}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Credit transferred</div><div className="h4 mb-0">${formatMoney(totals.total_credit_transferred)}</div></div></div>
</div>
{report.warnings?.length ? (
<div className="alert alert-warning mb-0">
<div className="fw-semibold mb-2">Warnings</div>
<ul className="mb-0">
{report.warnings.map((warning) => <li key={warning}>{warning}</li>)}
</ul>
</div>
) : null}
</div>
</div>
)
}
function BalanceTransfersView({ report }: { report: SchoolYearBalanceTransferReport }) {
const rows = report.rows ?? []
return (
<div className="card shadow-sm">
<div className="card-body">
<h2 className="h5 mb-3">Parent Balance Transfer Report</h2>
<div className="row g-3 mb-4">
<div className="col-md-4"><div className="border rounded p-3"><div className="small text-muted">Parents</div><div className="h4 mb-0">{report.summary?.parent_count ?? rows.length}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3"><div className="small text-muted">Total transferred</div><div className="h4 mb-0">${formatMoney(report.summary?.total_transferred)}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3"><div className="small text-muted">Total credit</div><div className="h4 mb-0">${formatMoney(report.summary?.total_credit)}</div></div></div>
</div>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th>Parent</th>
<th>Old year</th>
<th>New year</th>
<th>Source invoices</th>
<th className="text-end">Old unpaid</th>
<th>New invoice</th>
<th>Status</th>
<th>Transfer date</th>
<th>Created by</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={9} className="text-muted">No transfer rows found.</td></tr>
) : rows.map((row) => (
<tr key={`${row.parent_id}-${row.new_old_balance_invoice_id ?? 'none'}`}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.old_school_year ?? '—'}</td>
<td>{row.new_school_year ?? '—'}</td>
<td>{row.source_invoice_ids?.join(', ') || '—'}</td>
<td className="text-end">${formatMoney(row.old_unpaid_amount)}</td>
<td>{row.new_old_balance_invoice_id ?? '—'}</td>
<td>{row.transfer_status ?? '—'}</td>
<td>{formatDateTime(row.transfer_date)}</td>
<td>{row.created_by ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }) {
const { yearId } = useParams()
const id = Number(yearId)
const [closingReport, setClosingReport] = useState<SchoolYearClosingReport | null>(null)
const [balanceReport, setBalanceReport] = useState<SchoolYearBalanceTransferReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const title = useMemo(
() => reportType === 'closing' ? 'Closing Report' : 'Balance Transfer Report',
[reportType],
)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) {
setError('Invalid school year id.')
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
setClosingReport(null)
setBalanceReport(null)
;(async () => {
try {
if (reportType === 'closing') {
const report = await fetchSchoolYearClosingReport(id)
if (!cancelled) setClosingReport(report)
} else {
const report = await fetchSchoolYearBalanceTransferReport(id)
if (!cancelled) setBalanceReport(report)
}
} catch (loadError) {
if (!cancelled) setError(normalizeApiError(loadError, `Unable to load ${title.toLowerCase()}.`))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [id, reportType, title])
return (
<div className="container-fluid py-4">
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
<li className="breadcrumb-item"><Link to="/app/home">Home</Link></li>
<li className="breadcrumb-item"><Link to="/app/admin/school-years">School years</Link></li>
<li className="breadcrumb-item active" aria-current="page">{title}</li>
</ol>
</nav>
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 className="h3 mb-1">{title}</h1>
<p className="text-muted mb-0">
Report downloads must be served by authorized backend routes with private file storage.
This screen only displays records returned by those routes.
</p>
</div>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${id}/summary`}>
Back to summary
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading report</p> : null}
{closingReport ? <ClosingReportView report={closingReport} /> : null}
{balanceReport ? <BalanceTransfersView report={balanceReport} /> : null}
</div>
)
}
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { CiFlashMessages, type CiFlashMessage } from '../../components/CiPartials'
import { SchoolYearSelector, SchoolYearStatusBadge } from '../../components/schoolYear'
import { isReadOnlySchoolYear } from '../../lib/schoolYearSelection'
import { ApiHttpError } from '../../api/http'
import {
closeSchoolYear,
@@ -15,6 +17,7 @@ import {
validateSchoolYearClose,
type CloseSchoolYearPayload,
type SchoolYearClosePreview,
type SchoolYearCloseResult,
type SchoolYearCloseValidation,
type SchoolYearParentBalancePreview,
type SchoolYearPromotionPreview,
@@ -75,19 +78,6 @@ function formatMoney(value: number | null | undefined): string {
})
}
function statusBadge(status: string): string {
switch (status) {
case 'active':
return 'success'
case 'closed':
return 'danger'
case 'draft':
return 'warning'
default:
return 'secondary'
}
}
function suggestNextName(name: string): string {
const fullRange = name.match(/^(\d{4})\s*-\s*(\d{4})$/)
if (fullRange) {
@@ -143,6 +133,7 @@ export function SchoolYearsManagementPage() {
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
const [closeValidation, setCloseValidation] = useState<SchoolYearCloseValidation | null>(null)
const [closePreview, setClosePreview] = useState<SchoolYearClosePreview | null>(null)
const [closeResult, setCloseResult] = useState<SchoolYearCloseResult | null>(null)
const [createForm, setCreateForm] = useState<YearFormState>(emptyYearForm())
const [editForm, setEditForm] = useState<YearFormState>(emptyYearForm())
const [closeForm, setCloseForm] = useState<CloseFormState>(emptyCloseForm())
@@ -213,6 +204,7 @@ export function SchoolYearsManagementPage() {
setParentBalances(null)
setCloseValidation(null)
setClosePreview(null)
setCloseResult(null)
setError(normalizeApiError(loadError, 'Unable to load school years.'))
} finally {
setLoading(false)
@@ -289,6 +281,7 @@ export function SchoolYearsManagementPage() {
})
setPromotionPreview(null)
setParentBalances(null)
setCloseResult(null)
}
async function handleCreate(event: FormEvent) {
@@ -377,6 +370,7 @@ export function SchoolYearsManagementPage() {
const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
setCloseValidation(validation)
setClosePreview(null)
setCloseResult(null)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to validate school year closure.'))
} finally {
@@ -392,6 +386,7 @@ export function SchoolYearsManagementPage() {
const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
setCloseValidation(preview.validation)
setClosePreview(preview)
setCloseResult(null)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to preview school year closure.'))
} finally {
@@ -412,6 +407,7 @@ export function SchoolYearsManagementPage() {
)
setCloseValidation(null)
setClosePreview(null)
setCloseResult(result)
setPromotionPreview(null)
setParentBalances(null)
setCloseForm(emptyCloseForm())
@@ -428,6 +424,9 @@ export function SchoolYearsManagementPage() {
const balanceRows = parentBalances?.rows ?? []
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
const closeBalanceRows = closePreview?.parent_balances.rows ?? []
const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear)
const requiredConfirmation = activeYear ? `CLOSE ${activeYear.name}` : ''
const confirmationMatches = !activeYear || closeForm.confirmation.trim() === requiredConfirmation
return (
<div className="container-fluid py-4">
@@ -453,8 +452,11 @@ export function SchoolYearsManagementPage() {
parent balance carryover, and year closure through the matching Laravel API routes.
</p>
</div>
<div className="text-muted small">
Uses <code>/api/v1/school-years</code> with JWT <code>Authorization: Bearer</code>.
<div className="d-flex flex-column align-items-end gap-2">
<SchoolYearSelector />
<div className="text-muted small">
Uses <code>/api/v1/school-years</code> with JWT <code>Authorization: Bearer</code>.
</div>
</div>
</div>
@@ -490,9 +492,7 @@ export function SchoolYearsManagementPage() {
{toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
</div>
</div>
<span className={`badge text-bg-${statusBadge(year.status)}`}>
{year.status.toUpperCase()}
</span>
<SchoolYearStatusBadge status={year.status} />
</div>
<div className="d-flex flex-wrap gap-2">
<button
@@ -502,6 +502,12 @@ export function SchoolYearsManagementPage() {
>
Inspect
</button>
<Link
className="btn btn-sm btn-outline-secondary"
to={`/app/admin/school-years/${year.id}/summary`}
>
Summary
</Link>
{!year.is_current ? (
<button
type="button"
@@ -604,9 +610,7 @@ export function SchoolYearsManagementPage() {
</div>
</div>
{selectedYear ? (
<span className={`badge text-bg-${statusBadge(selectedYear.status)}`}>
{selectedYear.status.toUpperCase()}
</span>
<SchoolYearStatusBadge status={selectedYear.status} />
) : null}
</div>
@@ -645,9 +649,28 @@ export function SchoolYearsManagementPage() {
</div>
</div>
{selectedYear ? (
<div className="d-flex flex-wrap gap-2 mb-4">
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${selectedYear.id}/summary`}>
Open summary page
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${selectedYear.id}/reports/closing`}>
Closing report
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${selectedYear.id}/reports/balance-transfers`}>
Balance transfer report
</Link>
</div>
) : null}
<div className="card shadow-sm mb-4">
<div className="card-body">
<h2 className="h5 mb-3">Edit Selected Year</h2>
{selectedYearReadOnly ? (
<div className="alert alert-warning small">
This year is closed or archived. Metadata editing is disabled here; backend guards must reject the write anyway.
</div>
) : null}
{!selectedYear ? (
<p className="text-muted mb-0">Select a school year to edit its metadata.</p>
) : (
@@ -660,6 +683,7 @@ export function SchoolYearsManagementPage() {
id="edit_school_year_name"
className="form-control"
value={editForm.name}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, name: event.target.value }))
}
@@ -676,6 +700,7 @@ export function SchoolYearsManagementPage() {
className="form-control"
type="date"
value={editForm.start_date}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, start_date: event.target.value }))
}
@@ -691,6 +716,7 @@ export function SchoolYearsManagementPage() {
className="form-control"
type="date"
value={editForm.end_date}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, end_date: event.target.value }))
}
@@ -702,7 +728,7 @@ export function SchoolYearsManagementPage() {
Safe renames depend on whether finance and enrollment records already reference
the existing year label.
</div>
<button type="submit" className="btn btn-primary mt-3" disabled={busyEdit}>
<button type="submit" className="btn btn-primary mt-3" disabled={busyEdit || selectedYearReadOnly}>
{busyEdit ? 'Saving…' : 'Save changes'}
</button>
</form>
@@ -859,6 +885,21 @@ export function SchoolYearsManagementPage() {
{!activeYear ? (
<p className="text-muted mb-0">No active school year is available to close.</p>
) : (
<>
<div className="row g-2 mb-3 small" aria-label="Close-year wizard steps">
{[
'1. New Year Details',
'2. Academic Promotion Preview',
'3. Financial Balance Preview',
'4. Validation Results',
'5. Confirmation',
'6. Completion Report',
].map((step) => (
<div className="col-md-4" key={step}>
<div className="border rounded px-2 py-1 bg-light">{step}</div>
</div>
))}
</div>
<form onSubmit={(event) => void handleClose(event)}>
<div className="row g-3">
<div className="col-md-4">
@@ -922,9 +963,12 @@ export function SchoolYearsManagementPage() {
confirmation: event.target.value,
}))
}
placeholder={`CLOSE ${activeYear.name}`}
placeholder={requiredConfirmation}
required
/>
<div className={confirmationMatches ? 'form-text' : 'form-text text-danger'}>
Type exactly <code>{requiredConfirmation}</code>. Button-click roulette is not a control.
</div>
</div>
<div className="col-md-4 d-flex align-items-end">
<div className="form-check mb-2">
@@ -964,11 +1008,16 @@ export function SchoolYearsManagementPage() {
>
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
</button>
<button type="submit" className="btn btn-danger" disabled={busyCloseAction === 'close'}>
<button
type="submit"
className="btn btn-danger"
disabled={busyCloseAction === 'close' || !confirmationMatches}
>
{busyCloseAction === 'close' ? 'Closing…' : `Close ${activeYear.name}`}
</button>
</div>
</form>
</>
)}
{closeValidation ? (
@@ -1007,6 +1056,18 @@ export function SchoolYearsManagementPage() {
</div>
) : null}
{closeResult ? (
<div className="alert alert-success mt-4">
<div className="fw-semibold mb-2">Completion report</div>
<div className="row g-3 small">
<div className="col-md-6">Closed year: <strong>{closeResult.closed_school_year.name}</strong></div>
<div className="col-md-6">New active year: <strong>{closeResult.new_school_year.name}</strong></div>
<div className="col-md-6">Promotions: <strong>{Object.values(closeResult.promotion_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}</strong></div>
<div className="col-md-6">Balance transfer rows: <strong>{Object.values(closeResult.balance_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}</strong></div>
</div>
</div>
) : null}
{closePreview ? (
<div className="mt-4">
<div className="row g-4">
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useState } from 'react'
import { ApiHttpError } from '../../api/http'
import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears'
import { SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
function normalizeApiError(error: unknown, fallback: string): string {
if (error instanceof ApiHttpError) return error.message || fallback
if (error instanceof Error) return error.message
return fallback
}
function formatMoney(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
export function ParentStatementPage() {
const { selectedSchoolYearId, selectedSchoolYearName } = useSchoolYear()
const [statement, setStatement] = useState<ParentStatement | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (selectedSchoolYearId == null) return
let cancelled = false
setLoading(true)
setError(null)
;(async () => {
try {
const nextStatement = await fetchParentStatement({ schoolYearId: selectedSchoolYearId })
if (!cancelled) setStatement(nextStatement)
} catch (loadError) {
if (!cancelled) setError(normalizeApiError(loadError, 'Unable to load parent statement.'))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [selectedSchoolYearId])
const lines = useMemo(() => statement?.lines ?? [], [statement])
return (
<div className="container py-4">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 className="h3 mb-1">Parent Statement</h1>
<p className="text-muted mb-0">
Opening balances from closed years are shown separately from new-year charges so they are not double-counted.
</p>
</div>
<SchoolYearSelector />
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading statement</p> : null}
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex flex-wrap justify-content-between gap-3 mb-3">
<div>
<h2 className="h5 mb-1">{statement?.parent_name ?? 'Parent account'}</h2>
<div className="text-muted small">School year: {selectedSchoolYearName ?? '—'}</div>
</div>
<div className="text-end">
<div className="small text-muted">Current balance</div>
<div className="h4 mb-0">${formatMoney(statement?.current_balance)}</div>
</div>
</div>
<div className="alert alert-info small">
Opening balance from prior year: <strong>${formatMoney(statement?.opening_balance)}</strong>. The old-balance invoice is the collectible item; this opening-balance label is audit context, not a second charge.
</div>
<div className="table-responsive">
<table className="table align-middle">
<thead>
<tr>
<th>Description</th>
<th>Source year</th>
<th>Invoice</th>
<th className="text-end">Amount</th>
</tr>
</thead>
<tbody>
{lines.length === 0 ? (
<tr><td colSpan={4} className="text-muted">No statement lines found.</td></tr>
) : lines.map((line, index) => (
<tr key={`${line.label}-${index}`}>
<td>{line.label}</td>
<td>{line.source_school_year ?? '—'}</td>
<td>{line.invoice_id ?? '—'}</td>
<td className="text-end">${formatMoney(line.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}