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