add school year and security fix
This commit is contained in:
+28
@@ -73,3 +73,31 @@ body {
|
|||||||
color: #15654f;
|
color: #15654f;
|
||||||
font-weight: 600;
|
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
@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { AuthProvider } from './auth/AuthProvider'
|
import { AuthProvider } from './auth/AuthProvider'
|
||||||
import { RequireAuth } from './auth/RequireAuth'
|
import { RequireAuth } from './auth/RequireAuth'
|
||||||
import { GlobalTableSorting } from './components/GlobalTableSorting'
|
import { GlobalTableSorting } from './components/GlobalTableSorting'
|
||||||
|
import { SchoolYearProvider } from './context/SchoolYearContext'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
const PublicLayout = lazy(() =>
|
const PublicLayout = lazy(() =>
|
||||||
@@ -82,6 +83,21 @@ const SchoolYearsManagementPage = lazy(() =>
|
|||||||
default: m.SchoolYearsManagementPage,
|
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(() =>
|
const NavBuilderPage = lazy(() =>
|
||||||
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
|
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
|
||||||
)
|
)
|
||||||
@@ -973,6 +989,11 @@ const ParentPaymentInvoicesPage = lazy(() =>
|
|||||||
default: m.ParentPaymentInvoicesPage,
|
default: m.ParentPaymentInvoicesPage,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
const ParentStatementPage = lazy(() =>
|
||||||
|
import('./pages/parent/ParentStatementPage').then((m) => ({
|
||||||
|
default: m.ParentStatementPage,
|
||||||
|
})),
|
||||||
|
)
|
||||||
const ParentRegisterStudentPage = lazy(() =>
|
const ParentRegisterStudentPage = lazy(() =>
|
||||||
import('./pages/parent/WiredParentScreens').then((m) => ({
|
import('./pages/parent/WiredParentScreens').then((m) => ({
|
||||||
default: m.ParentRegisterStudentPage,
|
default: m.ParentRegisterStudentPage,
|
||||||
@@ -992,6 +1013,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<SchoolYearProvider>
|
||||||
<GlobalTableSorting />
|
<GlobalTableSorting />
|
||||||
<Suspense fallback={<RouteFallback />}>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -1132,6 +1154,17 @@ export default function App() {
|
|||||||
<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/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" 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 />} />
|
||||||
@@ -1481,6 +1514,7 @@ export default function App() {
|
|||||||
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
|
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
|
||||||
<Route path="parent/events" element={<ParentEventsPage />} />
|
<Route path="parent/events" element={<ParentEventsPage />} />
|
||||||
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
|
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
|
||||||
|
<Route path="parent/statements" element={<ParentStatementPage />} />
|
||||||
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
|
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
|
||||||
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
|
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
|
||||||
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
|
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
|
||||||
@@ -1513,6 +1547,7 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</SchoolYearProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
import type { ApiEnvelope } from './types'
|
import type { ApiEnvelope } from './types'
|
||||||
|
|
||||||
export type CertificateDecisionRow = {
|
export type CertificateDecisionRow = {
|
||||||
@@ -74,6 +74,7 @@ function authHeaders(accept: string) {
|
|||||||
const headers = new Headers({ Accept: accept })
|
const headers = new Headers({ Accept: accept })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
import { selectedSchoolYearHeaders } from '../lib/schoolYearSelection'
|
||||||
|
|
||||||
const TOKEN_KEY = 'alrahma_api_token'
|
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 {
|
export class ApiHttpError extends Error {
|
||||||
readonly status: number
|
readonly status: number
|
||||||
readonly body: unknown
|
readonly body: unknown
|
||||||
@@ -60,6 +69,10 @@ export async function apiFetch<T>(
|
|||||||
headers.set('Authorization', `Bearer ${token}`)
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachAuth && token) {
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
|
}
|
||||||
|
|
||||||
const url = apiUrl(path)
|
const url = apiUrl(path)
|
||||||
|
|
||||||
let res: Response
|
let res: Response
|
||||||
|
|||||||
+6
-7
@@ -2,7 +2,7 @@
|
|||||||
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
|
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { apiFetch, getStoredToken } from './http'
|
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
const BASE = '/api/v1/finance/invoices'
|
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). */
|
/** Binary PDF with JWT (for tabs that cannot send Authorization). */
|
||||||
export async function fetchInvoicePdfBlob(invoiceId: string | number): Promise<Blob> {
|
export async function fetchInvoicePdfBlob(invoiceId: string | number): Promise<Blob> {
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), {
|
const headers = new Headers({ Accept: 'application/pdf' })
|
||||||
headers: {
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
applyStoredSchoolYearHeaders(headers)
|
||||||
Accept: 'application/pdf',
|
|
||||||
},
|
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), { headers })
|
||||||
})
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error(text || `PDF HTTP ${res.status}`)
|
throw new Error(text || `PDF HTTP ${res.status}`)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
|
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
export type AdminNotificationRow = {
|
export type AdminNotificationRow = {
|
||||||
id?: number
|
id?: number
|
||||||
@@ -56,6 +56,7 @@ async function postMultipart<T>(path: string, formData: FormData): Promise<T> {
|
|||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -136,6 +137,7 @@ export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string
|
|||||||
const headers = new Headers({ Accept: '*/*' })
|
const headers = new Headers({ Accept: '*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(apiPath), { headers })
|
const res = await fetch(apiUrl(apiPath), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -335,6 +337,7 @@ export async function downloadFinancialReportCsv(params: {
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -408,6 +411,7 @@ export async function downloadFinancialSummaryPdf(params: { school_year?: string
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const raw = await res.blob()
|
const raw = await res.blob()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Teacher / admin print copy requests (legacy CI: print-requests/*).
|
* Teacher / admin print copy requests (legacy CI: print-requests/*).
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
export type PrintRequestRow = Record<string, unknown>
|
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 headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { method, headers, body: formData })
|
const res = await fetch(apiUrl(path), { method, headers, body: formData })
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -83,6 +84,7 @@ export async function openPrintRequestFile(requestId: number | string): Promise<
|
|||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
const headers = new Headers({ Accept: '*/*' })
|
const headers = new Headers({ Accept: '*/*' })
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(printRequestFileUrl(requestId)), { headers })
|
const res = await fetch(apiUrl(printRequestFileUrl(requestId)), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Stickers, badges, report cards (legacy CI printables_reports/*).
|
* Stickers, badges, report cards (legacy CI printables_reports/*).
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
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 }
|
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 headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl('/api/v1/administrator/printables/stickers/generate'), {
|
const res = await fetch(apiUrl('/api/v1/administrator/printables/stickers/generate'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -61,6 +62,7 @@ export async function postBadgeGenerate(formData: FormData): Promise<Blob> {
|
|||||||
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl('/api/v1/badges/pdf'), {
|
const res = await fetch(apiUrl('/api/v1/badges/pdf'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -242,6 +244,7 @@ export async function fetchReportCardStudentHtml(
|
|||||||
const headers = new Headers({ Accept: 'text/html' })
|
const headers = new Headers({ Accept: 'text/html' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const path = `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
|
const path = `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
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 headers = new Headers({ Accept: 'text/html' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const path = `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
|
const path = `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
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 headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(url, { headers })
|
const res = await fetch(url, { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const raw = await res.blob()
|
const raw = await res.blob()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Backend is expected under `/api/v1/administrator/reimbursements`.
|
* Backend is expected under `/api/v1/administrator/reimbursements`.
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
const BASE = '/api/v1/administrator/reimbursements'
|
const BASE = '/api/v1/administrator/reimbursements'
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ export async function downloadReimbursementsExport(params?: {
|
|||||||
const headers = new Headers({ Accept: 'text/csv,*/*' })
|
const headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(url, { headers })
|
const res = await fetch(url, { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
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 headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(url, { headers })
|
const res = await fetch(url, { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
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 headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(`${BASE}/batch/send`), { method: 'POST', headers, body: formData })
|
const res = await fetch(apiUrl(`${BASE}/batch/send`), { method: 'POST', headers, body: formData })
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
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 headers = new Headers({ Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: form })
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: form })
|
||||||
const body: unknown = await res.json().catch(() => ({}))
|
const body: unknown = await res.json().catch(() => ({}))
|
||||||
const data = body as Record<string, unknown>
|
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 headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type SchoolYearRecord = {
|
|||||||
end_date: string
|
end_date: string
|
||||||
status: SchoolYearStatus
|
status: SchoolYearStatus
|
||||||
is_current: boolean
|
is_current: boolean
|
||||||
|
isCurrent?: boolean
|
||||||
closed_at?: string | null
|
closed_at?: string | null
|
||||||
closed_by?: number | null
|
closed_by?: number | null
|
||||||
}
|
}
|
||||||
@@ -95,6 +96,65 @@ export type SchoolYearCloseResult = {
|
|||||||
balance_counts?: Record<string, number>
|
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 = {
|
export type SaveSchoolYearPayload = {
|
||||||
name: string
|
name: string
|
||||||
start_date: string
|
start_date: string
|
||||||
@@ -103,6 +163,15 @@ export type SaveSchoolYearPayload = {
|
|||||||
|
|
||||||
export type CloseSchoolYearPayload = {
|
export type CloseSchoolYearPayload = {
|
||||||
new_school_year: SaveSchoolYearPayload
|
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
|
transfer_unpaid_balances?: boolean
|
||||||
confirmation?: string
|
confirmation?: string
|
||||||
}
|
}
|
||||||
@@ -147,6 +216,21 @@ type ApiCloseResponse = {
|
|||||||
data?: SchoolYearCloseResult
|
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[]> {
|
export async function fetchSchoolYears(): Promise<SchoolYearRecord[]> {
|
||||||
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
|
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
|
||||||
return response.data ?? []
|
return response.data ?? []
|
||||||
@@ -292,3 +376,51 @@ export async function fetchSchoolYearPromotionPreview(
|
|||||||
|
|
||||||
return response.data
|
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
@@ -1,5 +1,5 @@
|
|||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
import type {
|
import type {
|
||||||
AdministratorAbsenceFormResponse,
|
AdministratorAbsenceFormResponse,
|
||||||
AdministratorAbsenceSubmitResponse,
|
AdministratorAbsenceSubmitResponse,
|
||||||
@@ -595,6 +595,7 @@ export async function uploadEarlyDismissalSignature(formData: FormData): Promise
|
|||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl('/api/v1/attendance/early-dismissals/signature'), {
|
const res = await fetch(apiUrl('/api/v1/attendance/early-dismissals/signature'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -1224,6 +1225,7 @@ export async function openProtectedApiFile(path: string): Promise<void> {
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
|
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -1255,6 +1257,7 @@ async function fetchMultipartJson<T>(
|
|||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), {
|
const res = await fetch(apiUrl(path), {
|
||||||
method: init.method ?? 'POST',
|
method: init.method ?? 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -1788,6 +1791,7 @@ export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promi
|
|||||||
const headers = new Headers({ Accept: 'text/csv' })
|
const headers = new Headers({ Accept: 'text/csv' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (params?.q) query.set('q', params.q)
|
if (params?.q) query.set('q', params.q)
|
||||||
const suffix = query.size > 0 ? `?${query.toString()}` : ''
|
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 headers = new Headers({ Accept: 'application/pdf' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(`/api/v1/reports/report-cards/students/${studentId}`), {
|
const res = await fetch(apiUrl(`/api/v1/reports/report-cards/students/${studentId}`), {
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Teacher portal API (`Views/teacher/*` parity).
|
* Teacher portal API (`Views/teacher/*` parity).
|
||||||
* Laravel should expose matching routes under `/api/v1/teacher/...`.
|
* 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'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
|
||||||
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
|
/** 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 headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
|
|
||||||
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
|
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AuthUser = {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
roles: Record<string, boolean>
|
roles: Record<string, boolean>
|
||||||
|
permissions?: Record<string, boolean> | string[]
|
||||||
class_section_id?: number | null
|
class_section_id?: number | null
|
||||||
class_section_name?: string | 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { ReadOnlyBanner } from './ReadOnlyBanner'
|
||||||
|
export { ReadOnlyGuard } from './ReadOnlyGuard'
|
||||||
|
export { SchoolYearSelector } from './SchoolYearSelector'
|
||||||
|
export { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
import { CiPublicFooter } from '../components/CiPartials'
|
import { CiPublicFooter } from '../components/CiPartials'
|
||||||
|
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
|
||||||
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
|
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
|
||||||
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
|
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/enroll-classes', icon: 'bi bi-pencil-square', label: 'Enroll in Classes' },
|
||||||
{ to: '/app/parent/events', icon: 'bi bi-bell', label: 'Events' },
|
{ to: '/app/parent/events', icon: 'bi bi-bell', label: 'Events' },
|
||||||
{ to: '/app/parent/payment', icon: 'bi bi-credit-card', label: 'Invoice' },
|
{ 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/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-attendance', icon: 'bi bi-megaphone', label: 'Report Absence' },
|
||||||
{ to: '/app/parent/report-cards', icon: 'bi bi-file-earmark-text', label: 'Report Cards' },
|
{ to: '/app/parent/report-cards', icon: 'bi bi-file-earmark-text', label: 'Report Cards' },
|
||||||
@@ -214,6 +216,11 @@ export function MainLayout() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
{user ? (
|
||||||
|
<div className="d-none d-lg-flex align-items-center me-2">
|
||||||
|
<SchoolYearSelector compact />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{user ? (
|
{user ? (
|
||||||
<div
|
<div
|
||||||
ref={clusterRef}
|
ref={clusterRef}
|
||||||
@@ -333,6 +340,7 @@ export function MainLayout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mt-4 flex-grow-1">
|
<main className="container mt-4 flex-grow-1">
|
||||||
|
<ReadOnlyBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiHttpError } from '../api/http'
|
|||||||
import { fetchNavMenu } from '../api/session'
|
import { fetchNavMenu } from '../api/session'
|
||||||
import type { NavItem } from '../api/types'
|
import type { NavItem } from '../api/types'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
|
||||||
import {
|
import {
|
||||||
isExternalNavUrl,
|
isExternalNavUrl,
|
||||||
spaPathFromCiUrl,
|
spaPathFromCiUrl,
|
||||||
@@ -230,6 +231,11 @@ export function ManagementLayout() {
|
|||||||
|
|
||||||
const navTree = useMemo(() => normalizeNavItems(items), [items])
|
const navTree = useMemo(() => normalizeNavItems(items), [items])
|
||||||
|
|
||||||
|
// Auto-hide sidebar after navigating to a subject
|
||||||
|
useEffect(() => {
|
||||||
|
setSidebarOpen(false)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
|
<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">
|
<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
|
Al Rahma Sunday School
|
||||||
</Link>
|
</Link>
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<SchoolYearSelector compact />
|
||||||
<span className="navbar-text small mb-0">{user?.name}</span>
|
<span className="navbar-text small mb-0">{user?.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -309,6 +316,7 @@ export function ManagementLayout() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="content management-main">
|
<main className="content management-main">
|
||||||
|
<ReadOnlyBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function makeOption(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function optionFromRecord(record: SchoolYearRecord): SchoolYearOption | null {
|
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 {
|
function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null {
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { CiFlashMessages, type CiFlashMessage } from '../../components/CiPartials'
|
import { CiFlashMessages, type CiFlashMessage } from '../../components/CiPartials'
|
||||||
|
import { SchoolYearSelector, SchoolYearStatusBadge } from '../../components/schoolYear'
|
||||||
|
import { isReadOnlySchoolYear } from '../../lib/schoolYearSelection'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
import {
|
import {
|
||||||
closeSchoolYear,
|
closeSchoolYear,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
validateSchoolYearClose,
|
validateSchoolYearClose,
|
||||||
type CloseSchoolYearPayload,
|
type CloseSchoolYearPayload,
|
||||||
type SchoolYearClosePreview,
|
type SchoolYearClosePreview,
|
||||||
|
type SchoolYearCloseResult,
|
||||||
type SchoolYearCloseValidation,
|
type SchoolYearCloseValidation,
|
||||||
type SchoolYearParentBalancePreview,
|
type SchoolYearParentBalancePreview,
|
||||||
type SchoolYearPromotionPreview,
|
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 {
|
function suggestNextName(name: string): string {
|
||||||
const fullRange = name.match(/^(\d{4})\s*-\s*(\d{4})$/)
|
const fullRange = name.match(/^(\d{4})\s*-\s*(\d{4})$/)
|
||||||
if (fullRange) {
|
if (fullRange) {
|
||||||
@@ -143,6 +133,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
|
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
|
||||||
const [closeValidation, setCloseValidation] = useState<SchoolYearCloseValidation | null>(null)
|
const [closeValidation, setCloseValidation] = useState<SchoolYearCloseValidation | null>(null)
|
||||||
const [closePreview, setClosePreview] = useState<SchoolYearClosePreview | null>(null)
|
const [closePreview, setClosePreview] = useState<SchoolYearClosePreview | null>(null)
|
||||||
|
const [closeResult, setCloseResult] = useState<SchoolYearCloseResult | null>(null)
|
||||||
const [createForm, setCreateForm] = useState<YearFormState>(emptyYearForm())
|
const [createForm, setCreateForm] = useState<YearFormState>(emptyYearForm())
|
||||||
const [editForm, setEditForm] = useState<YearFormState>(emptyYearForm())
|
const [editForm, setEditForm] = useState<YearFormState>(emptyYearForm())
|
||||||
const [closeForm, setCloseForm] = useState<CloseFormState>(emptyCloseForm())
|
const [closeForm, setCloseForm] = useState<CloseFormState>(emptyCloseForm())
|
||||||
@@ -213,6 +204,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
setParentBalances(null)
|
setParentBalances(null)
|
||||||
setCloseValidation(null)
|
setCloseValidation(null)
|
||||||
setClosePreview(null)
|
setClosePreview(null)
|
||||||
|
setCloseResult(null)
|
||||||
setError(normalizeApiError(loadError, 'Unable to load school years.'))
|
setError(normalizeApiError(loadError, 'Unable to load school years.'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -289,6 +281,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
})
|
})
|
||||||
setPromotionPreview(null)
|
setPromotionPreview(null)
|
||||||
setParentBalances(null)
|
setParentBalances(null)
|
||||||
|
setCloseResult(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(event: FormEvent) {
|
async function handleCreate(event: FormEvent) {
|
||||||
@@ -377,6 +370,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
|
const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
|
||||||
setCloseValidation(validation)
|
setCloseValidation(validation)
|
||||||
setClosePreview(null)
|
setClosePreview(null)
|
||||||
|
setCloseResult(null)
|
||||||
} catch (actionError) {
|
} catch (actionError) {
|
||||||
setError(normalizeApiError(actionError, 'Unable to validate school year closure.'))
|
setError(normalizeApiError(actionError, 'Unable to validate school year closure.'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -392,6 +386,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
|
const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
|
||||||
setCloseValidation(preview.validation)
|
setCloseValidation(preview.validation)
|
||||||
setClosePreview(preview)
|
setClosePreview(preview)
|
||||||
|
setCloseResult(null)
|
||||||
} catch (actionError) {
|
} catch (actionError) {
|
||||||
setError(normalizeApiError(actionError, 'Unable to preview school year closure.'))
|
setError(normalizeApiError(actionError, 'Unable to preview school year closure.'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -412,6 +407,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
)
|
)
|
||||||
setCloseValidation(null)
|
setCloseValidation(null)
|
||||||
setClosePreview(null)
|
setClosePreview(null)
|
||||||
|
setCloseResult(result)
|
||||||
setPromotionPreview(null)
|
setPromotionPreview(null)
|
||||||
setParentBalances(null)
|
setParentBalances(null)
|
||||||
setCloseForm(emptyCloseForm())
|
setCloseForm(emptyCloseForm())
|
||||||
@@ -428,6 +424,9 @@ export function SchoolYearsManagementPage() {
|
|||||||
const balanceRows = parentBalances?.rows ?? []
|
const balanceRows = parentBalances?.rows ?? []
|
||||||
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
|
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
|
||||||
const closeBalanceRows = closePreview?.parent_balances.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 (
|
return (
|
||||||
<div className="container-fluid py-4">
|
<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.
|
parent balance carryover, and year closure through the matching Laravel API routes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted small">
|
<div className="d-flex flex-column align-items-end gap-2">
|
||||||
Uses <code>/api/v1/school-years</code> with JWT <code>Authorization: Bearer</code>.
|
<SchoolYearSelector />
|
||||||
|
<div className="text-muted small">
|
||||||
|
Uses <code>/api/v1/school-years</code> with JWT <code>Authorization: Bearer</code>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -490,9 +492,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
{toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
|
{toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`badge text-bg-${statusBadge(year.status)}`}>
|
<SchoolYearStatusBadge status={year.status} />
|
||||||
{year.status.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="d-flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -502,6 +502,12 @@ export function SchoolYearsManagementPage() {
|
|||||||
>
|
>
|
||||||
Inspect
|
Inspect
|
||||||
</button>
|
</button>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
to={`/app/admin/school-years/${year.id}/summary`}
|
||||||
|
>
|
||||||
|
Summary
|
||||||
|
</Link>
|
||||||
{!year.is_current ? (
|
{!year.is_current ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -604,9 +610,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedYear ? (
|
{selectedYear ? (
|
||||||
<span className={`badge text-bg-${statusBadge(selectedYear.status)}`}>
|
<SchoolYearStatusBadge status={selectedYear.status} />
|
||||||
{selectedYear.status.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -645,9 +649,28 @@ export function SchoolYearsManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 shadow-sm mb-4">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="h5 mb-3">Edit Selected Year</h2>
|
<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 ? (
|
{!selectedYear ? (
|
||||||
<p className="text-muted mb-0">Select a school year to edit its metadata.</p>
|
<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"
|
id="edit_school_year_name"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={editForm.name}
|
value={editForm.name}
|
||||||
|
disabled={selectedYearReadOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setEditForm((current) => ({ ...current, name: event.target.value }))
|
setEditForm((current) => ({ ...current, name: event.target.value }))
|
||||||
}
|
}
|
||||||
@@ -676,6 +700,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
className="form-control"
|
className="form-control"
|
||||||
type="date"
|
type="date"
|
||||||
value={editForm.start_date}
|
value={editForm.start_date}
|
||||||
|
disabled={selectedYearReadOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setEditForm((current) => ({ ...current, start_date: event.target.value }))
|
setEditForm((current) => ({ ...current, start_date: event.target.value }))
|
||||||
}
|
}
|
||||||
@@ -691,6 +716,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
className="form-control"
|
className="form-control"
|
||||||
type="date"
|
type="date"
|
||||||
value={editForm.end_date}
|
value={editForm.end_date}
|
||||||
|
disabled={selectedYearReadOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setEditForm((current) => ({ ...current, end_date: event.target.value }))
|
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
|
Safe renames depend on whether finance and enrollment records already reference
|
||||||
the existing year label.
|
the existing year label.
|
||||||
</div>
|
</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'}
|
{busyEdit ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -859,6 +885,21 @@ export function SchoolYearsManagementPage() {
|
|||||||
{!activeYear ? (
|
{!activeYear ? (
|
||||||
<p className="text-muted mb-0">No active school year is available to close.</p>
|
<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)}>
|
<form onSubmit={(event) => void handleClose(event)}>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
@@ -922,9 +963,12 @@ export function SchoolYearsManagementPage() {
|
|||||||
confirmation: event.target.value,
|
confirmation: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder={`CLOSE ${activeYear.name}`}
|
placeholder={requiredConfirmation}
|
||||||
required
|
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>
|
||||||
<div className="col-md-4 d-flex align-items-end">
|
<div className="col-md-4 d-flex align-items-end">
|
||||||
<div className="form-check mb-2">
|
<div className="form-check mb-2">
|
||||||
@@ -964,11 +1008,16 @@ export function SchoolYearsManagementPage() {
|
|||||||
>
|
>
|
||||||
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
|
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
|
||||||
</button>
|
</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}`}
|
{busyCloseAction === 'close' ? 'Closing…' : `Close ${activeYear.name}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{closeValidation ? (
|
{closeValidation ? (
|
||||||
@@ -1007,6 +1056,18 @@ export function SchoolYearsManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{closePreview ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user