From b74ddca8b135c4403d5104eb4b7eb56a6d263dd2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 00:55:02 -0400 Subject: [PATCH] add school year and security fix --- src/App.css | 28 +++ src/App.tsx | 35 +++ src/api/certificates.ts | 3 +- src/api/http.ts | 13 ++ src/api/invoices.ts | 13 +- src/api/paymentManagement.ts | 6 +- src/api/printRequests.ts | 4 +- src/api/printables.ts | 7 +- src/api/reimbursements.ts | 7 +- src/api/schoolYears.ts | 132 ++++++++++++ src/api/session.ts | 7 +- src/api/teacherSession.ts | 3 +- src/api/types.ts | 1 + src/components/schoolYear/ReadOnlyBanner.tsx | 23 ++ src/components/schoolYear/ReadOnlyGuard.tsx | 20 ++ .../schoolYear/SchoolYearSelector.tsx | 53 +++++ .../schoolYear/SchoolYearStatusBadge.tsx | 31 +++ src/components/schoolYear/index.ts | 4 + src/context/SchoolYearContext.tsx | 180 ++++++++++++++++ src/layout/MainLayout.tsx | 8 + src/layout/ManagementLayout.tsx | 8 + src/lib/schoolYearOptions.ts | 2 +- src/lib/schoolYearSelection.ts | 108 ++++++++++ .../administrator/SchoolYearDetailPage.tsx | 178 +++++++++++++++ .../administrator/SchoolYearReportsPage.tsx | 203 ++++++++++++++++++ .../SchoolYearsManagementPage.tsx | 109 +++++++--- src/pages/parent/ParentStatementPage.tsx | 111 ++++++++++ 27 files changed, 1258 insertions(+), 39 deletions(-) create mode 100644 src/components/schoolYear/ReadOnlyBanner.tsx create mode 100644 src/components/schoolYear/ReadOnlyGuard.tsx create mode 100644 src/components/schoolYear/SchoolYearSelector.tsx create mode 100644 src/components/schoolYear/SchoolYearStatusBadge.tsx create mode 100644 src/components/schoolYear/index.ts create mode 100644 src/context/SchoolYearContext.tsx create mode 100644 src/lib/schoolYearSelection.ts create mode 100644 src/pages/administrator/SchoolYearDetailPage.tsx create mode 100644 src/pages/administrator/SchoolYearReportsPage.tsx create mode 100644 src/pages/parent/ParentStatementPage.tsx diff --git a/src/App.css b/src/App.css index cc160d5..b1ffb03 100644 --- a/src/App.css +++ b/src/App.css @@ -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; +} diff --git a/src/App.tsx b/src/App.tsx index 68d8294..98ffeb9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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: () => , + })), +) +const SchoolYearBalanceTransfersReportPage = lazy(() => + import('./pages/administrator/SchoolYearReportsPage').then((m) => ({ + default: () => , + })), +) 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 ( + }> @@ -1132,6 +1154,17 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } + /> + } + /> } /> } /> } /> @@ -1481,6 +1514,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -1513,6 +1547,7 @@ export default function App() { } /> + ) diff --git a/src/api/certificates.ts b/src/api/certificates.ts index 43153d4..9b4d7d6 100644 --- a/src/api/certificates.ts +++ b/src/api/certificates.ts @@ -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 } diff --git a/src/api/http.ts b/src/api/http.ts index a7478de..9e95e05 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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(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( headers.set('Authorization', `Bearer ${token}`) } + if (attachAuth && token) { + applyStoredSchoolYearHeaders(headers) + } + const url = apiUrl(path) let res: Response diff --git a/src/api/invoices.ts b/src/api/invoices.ts index e04dc0c..00149f6 100644 --- a/src/api/invoices.ts +++ b/src/api/invoices.ts @@ -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 { 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}`) diff --git a/src/api/paymentManagement.ts b/src/api/paymentManagement.ts index 196f62c..501aa67 100644 --- a/src/api/paymentManagement.ts +++ b/src/api/paymentManagement.ts @@ -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(path: string, formData: FormData): Promise { 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 @@ -23,6 +23,7 @@ async function multipartRequest(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() diff --git a/src/api/printables.ts b/src/api/printables.ts index 4c211e3..7c77629 100644 --- a/src/api/printables.ts +++ b/src/api/printables.ts @@ -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 { 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 { 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 { 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() diff --git a/src/api/reimbursements.ts b/src/api/reimbursements.ts index 0691d75..e062b7d 100644 --- a/src/api/reimbursements.ts +++ b/src/api/reimbursements.ts @@ -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 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 @@ -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) { diff --git a/src/api/schoolYears.ts b/src/api/schoolYears.ts index 5c6ce34..c52d457 100644 --- a/src/api/schoolYears.ts +++ b/src/api/schoolYears.ts @@ -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 } +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 + 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 { const response = await apiFetch('/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 { + const response = await apiFetch( + `/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 { + const response = await apiFetch( + `/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 { + 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( + `/api/v1/parent/statements${query.toString() ? `?${query}` : ''}`, + ) + + if (!response.data) { + throw new Error('Parent statement returned no payload.') + } + + return response.data +} diff --git a/src/api/session.ts b/src/api/session.ts index b2bd240..d67de19 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -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 { 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( 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 { 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, }) diff --git a/src/api/teacherSession.ts b/src/api/teacherSession.ts index 43f435c..4a8b4d6 100644 --- a/src/api/teacherSession.ts +++ b/src/api/teacherSession.ts @@ -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(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}`) diff --git a/src/api/types.ts b/src/api/types.ts index 36c8869..c9192cd 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -2,6 +2,7 @@ export type AuthUser = { id: number name: string roles: Record + permissions?: Record | string[] class_section_id?: number | null class_section_name?: string | null } diff --git a/src/components/schoolYear/ReadOnlyBanner.tsx b/src/components/schoolYear/ReadOnlyBanner.tsx new file mode 100644 index 0000000..0586834 --- /dev/null +++ b/src/components/schoolYear/ReadOnlyBanner.tsx @@ -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 ( +
+ +
+
+ Viewing archived school year: {selectedSchoolYear.name} +
+
+ 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. +
+
+
+ ) +} diff --git a/src/components/schoolYear/ReadOnlyGuard.tsx b/src/components/schoolYear/ReadOnlyGuard.tsx new file mode 100644 index 0000000..68d0674 --- /dev/null +++ b/src/components/schoolYear/ReadOnlyGuard.tsx @@ -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 ( +
+ {fallback ?? `This action is disabled because ${selectedSchoolYear?.name ?? 'the selected year'} is read-only.`} +
+ ) +} diff --git a/src/components/schoolYear/SchoolYearSelector.tsx b/src/components/schoolYear/SchoolYearSelector.tsx new file mode 100644 index 0000000..2bc4a6e --- /dev/null +++ b/src/components/schoolYear/SchoolYearSelector.tsx @@ -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 ? ( + + School years unavailable + + ) : null + } + + return ( +
+ + + {selectedSchoolYear ? : null} +
+ ) +} diff --git a/src/components/schoolYear/SchoolYearStatusBadge.tsx b/src/components/schoolYear/SchoolYearStatusBadge.tsx new file mode 100644 index 0000000..5b0e316 --- /dev/null +++ b/src/components/schoolYear/SchoolYearStatusBadge.tsx @@ -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 ( + + {label.toUpperCase()} + + ) +} diff --git a/src/components/schoolYear/index.ts b/src/components/schoolYear/index.ts new file mode 100644 index 0000000..60ec279 --- /dev/null +++ b/src/components/schoolYear/index.ts @@ -0,0 +1,4 @@ +export { ReadOnlyBanner } from './ReadOnlyBanner' +export { ReadOnlyGuard } from './ReadOnlyGuard' +export { SchoolYearSelector } from './SchoolYearSelector' +export { SchoolYearStatusBadge } from './SchoolYearStatusBadge' diff --git a/src/context/SchoolYearContext.tsx b/src/context/SchoolYearContext.tsx new file mode 100644 index 0000000..2ad5def --- /dev/null +++ b/src/context/SchoolYearContext.tsx @@ -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 +} + +const SchoolYearContext = createContext(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([]) + const [requestedYearId, setRequestedYearId] = useState(null) + const [isLoadingSchoolYears, setIsLoadingSchoolYears] = useState(false) + const [schoolYearError, setSchoolYearError] = useState(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(() => ({ + 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 {children} +} + +export function useSchoolYear(): SchoolYearContextValue { + const context = useContext(SchoolYearContext) + if (!context) { + throw new Error('useSchoolYear must be used inside SchoolYearProvider.') + } + return context +} diff --git a/src/layout/MainLayout.tsx b/src/layout/MainLayout.tsx index 06958e9..d6a510c 100644 --- a/src/layout/MainLayout.tsx +++ b/src/layout/MainLayout.tsx @@ -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() { ))} + {user ? ( +
+ +
+ ) : null} {user ? (
+
diff --git a/src/layout/ManagementLayout.tsx b/src/layout/ManagementLayout.tsx index e1b478e..7d1b3f3 100644 --- a/src/layout/ManagementLayout.tsx +++ b/src/layout/ManagementLayout.tsx @@ -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 (
@@ -238,6 +244,7 @@ export function ManagementLayout() { Al Rahma Sunday School
+ {user?.name}
diff --git a/src/lib/schoolYearOptions.ts b/src/lib/schoolYearOptions.ts index 6880507..d855318 100644 --- a/src/lib/schoolYearOptions.ts +++ b/src/lib/schoolYearOptions.ts @@ -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 { diff --git a/src/lib/schoolYearSelection.ts b/src/lib/schoolYearSelection.ts new file mode 100644 index 0000000..4272cd7 --- /dev/null +++ b/src/lib/schoolYearSelection.ts @@ -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 & { 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 { + const id = getStoredSelectedSchoolYearId() + const name = getStoredSelectedSchoolYearName() + const status = getStoredSelectedSchoolYearStatus() + const headers: Record = {} + + 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}` : '' +} diff --git a/src/pages/administrator/SchoolYearDetailPage.tsx b/src/pages/administrator/SchoolYearDetailPage.tsx new file mode 100644 index 0000000..c1ff877 --- /dev/null +++ b/src/pages/administrator/SchoolYearDetailPage.tsx @@ -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(null) + const [promotionPreview, setPromotionPreview] = useState(null) + const [parentBalances, setParentBalances] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ + +
+
+

School Year Summary

+

+ Academic promotion, parent-level balance exposure, and closure readiness in one place. +

+
+ {summary?.school_year ? : null} +
+ + {error ?
{error}
: null} + {loading ?

Loading school year summary…

: null} + + {summary ? ( + <> +
+
+
+
Students considered
+
{totals?.students_considered ?? 0}
+
+
+
+
+
Students blocked
+
{totals?.students_blocked ?? 0}
+
+
+
+
+
Parents with balances
+
{totals?.parents_with_balances ?? 0}
+
+
+
+
+
Net transfer impact
+
${formatMoney(totals?.net_balance_to_transfer)}
+
+
+
+ +
+
+
+
+

Promotion summary

+
+
Promote
{promotion?.promote ?? 0}
+
Repeat
{promotion?.repeat ?? 0}
+
Graduate
{promotion?.graduate ?? 0}
+
Withdraw
{promotion?.withdraw ?? 0}
+
Missing decision
{promotion?.hold ?? 0}
+
+
+
+
+
+
+
+

Parent balance preview

+
+ {balances?.parents_with_non_zero_balance ?? 0} parents with non-zero balances, + net ${formatMoney(balances?.net_balance_to_transfer)}. +
+
+ + + + {rows.length === 0 ? ( + + ) : rows.map((row) => ( + + + + + + ))} + +
ParentStudentsTransfer
No balance rows found.
{row.parent_name || `Parent #${row.parent_id}`}{row.student_names?.join(', ') || '—'}${formatMoney(row.amount_to_transfer)}
+
+
+
+
+
+ + ) : null} +
+ ) +} diff --git a/src/pages/administrator/SchoolYearReportsPage.tsx b/src/pages/administrator/SchoolYearReportsPage.tsx new file mode 100644 index 0000000..d5b8131 --- /dev/null +++ b/src/pages/administrator/SchoolYearReportsPage.tsx @@ -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 ( +
+
+

Closing Report

+
+
Old school year
+
{report.old_school_year?.name ?? '—'}
+
New school year
+
{report.new_school_year?.name ?? '—'}
+
Closed by
+
{report.closed_by ?? '—'}
+
Closed at
+
{formatDateTime(report.closed_at)}
+
Audit reference
+
{report.audit_reference ?? '—'}
+
+ +
+
Promoted
{totals.students_promoted ?? 0}
+
Repeated
{totals.students_repeated ?? 0}
+
Graduated
{totals.students_graduated ?? 0}
+
Parents with unpaid balances
{totals.parents_with_unpaid_balances ?? 0}
+
Unpaid transferred
${formatMoney(totals.total_unpaid_balance_transferred)}
+
Credit transferred
${formatMoney(totals.total_credit_transferred)}
+
+ + {report.warnings?.length ? ( +
+
Warnings
+
    + {report.warnings.map((warning) =>
  • {warning}
  • )} +
+
+ ) : null} +
+
+ ) +} + +function BalanceTransfersView({ report }: { report: SchoolYearBalanceTransferReport }) { + const rows = report.rows ?? [] + + return ( +
+
+

Parent Balance Transfer Report

+
+
Parents
{report.summary?.parent_count ?? rows.length}
+
Total transferred
${formatMoney(report.summary?.total_transferred)}
+
Total credit
${formatMoney(report.summary?.total_credit)}
+
+ +
+ + + + + + + + + + + + + + + + {rows.length === 0 ? ( + + ) : rows.map((row) => ( + + + + + + + + + + + + ))} + +
ParentOld yearNew yearSource invoicesOld unpaidNew invoiceStatusTransfer dateCreated by
No transfer rows found.
{row.parent_name || `Parent #${row.parent_id}`}{row.old_school_year ?? '—'}{row.new_school_year ?? '—'}{row.source_invoice_ids?.join(', ') || '—'}${formatMoney(row.old_unpaid_amount)}{row.new_old_balance_invoice_id ?? '—'}{row.transfer_status ?? '—'}{formatDateTime(row.transfer_date)}{row.created_by ?? '—'}
+
+
+
+ ) +} + +export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }) { + const { yearId } = useParams() + const id = Number(yearId) + const [closingReport, setClosingReport] = useState(null) + const [balanceReport, setBalanceReport] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ + +
+
+

{title}

+

+ Report downloads must be served by authorized backend routes with private file storage. + This screen only displays records returned by those routes. +

+
+ + Back to summary + +
+ + {error ?
{error}
: null} + {loading ?

Loading report…

: null} + {closingReport ? : null} + {balanceReport ? : null} +
+ ) +} diff --git a/src/pages/administrator/SchoolYearsManagementPage.tsx b/src/pages/administrator/SchoolYearsManagementPage.tsx index aa39e32..e9e1bfc 100644 --- a/src/pages/administrator/SchoolYearsManagementPage.tsx +++ b/src/pages/administrator/SchoolYearsManagementPage.tsx @@ -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(null) const [closeValidation, setCloseValidation] = useState(null) const [closePreview, setClosePreview] = useState(null) + const [closeResult, setCloseResult] = useState(null) const [createForm, setCreateForm] = useState(emptyYearForm()) const [editForm, setEditForm] = useState(emptyYearForm()) const [closeForm, setCloseForm] = useState(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 (
@@ -453,8 +452,11 @@ export function SchoolYearsManagementPage() { parent balance carryover, and year closure through the matching Laravel API routes.

-
- Uses /api/v1/school-years with JWT Authorization: Bearer. +
+ +
+ Uses /api/v1/school-years with JWT Authorization: Bearer. +
@@ -490,9 +492,7 @@ export function SchoolYearsManagementPage() { {toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
- - {year.status.toUpperCase()} - +
+ + Summary + {!year.is_current ? (
{selectedYear ? ( - - {selectedYear.status.toUpperCase()} - + ) : null} @@ -645,9 +649,28 @@ export function SchoolYearsManagementPage() { + {selectedYear ? ( +
+ + Open summary page + + + Closing report + + + Balance transfer report + +
+ ) : null} +

Edit Selected Year

+ {selectedYearReadOnly ? ( +
+ This year is closed or archived. Metadata editing is disabled here; backend guards must reject the write anyway. +
+ ) : null} {!selectedYear ? (

Select a school year to edit its metadata.

) : ( @@ -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.
- @@ -859,6 +885,21 @@ export function SchoolYearsManagementPage() { {!activeYear ? (

No active school year is available to close.

) : ( + <> +
+ {[ + '1. New Year Details', + '2. Academic Promotion Preview', + '3. Financial Balance Preview', + '4. Validation Results', + '5. Confirmation', + '6. Completion Report', + ].map((step) => ( +
+
{step}
+
+ ))} +
void handleClose(event)}>
@@ -922,9 +963,12 @@ export function SchoolYearsManagementPage() { confirmation: event.target.value, })) } - placeholder={`CLOSE ${activeYear.name}`} + placeholder={requiredConfirmation} required /> +
+ Type exactly {requiredConfirmation}. Button-click roulette is not a control. +
@@ -964,11 +1008,16 @@ export function SchoolYearsManagementPage() { > {busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'} -
+ )} {closeValidation ? ( @@ -1007,6 +1056,18 @@ export function SchoolYearsManagementPage() {
) : null} + {closeResult ? ( +
+
Completion report
+
+
Closed year: {closeResult.closed_school_year.name}
+
New active year: {closeResult.new_school_year.name}
+
Promotions: {Object.values(closeResult.promotion_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}
+
Balance transfer rows: {Object.values(closeResult.balance_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}
+
+
+ ) : null} + {closePreview ? (
diff --git a/src/pages/parent/ParentStatementPage.tsx b/src/pages/parent/ParentStatementPage.tsx new file mode 100644 index 0000000..92dc573 --- /dev/null +++ b/src/pages/parent/ParentStatementPage.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+
+
+

Parent Statement

+

+ Opening balances from closed years are shown separately from new-year charges so they are not double-counted. +

+
+ +
+ + {error ?
{error}
: null} + {loading ?

Loading statement…

: null} + +
+
+
+
+

{statement?.parent_name ?? 'Parent account'}

+
School year: {selectedSchoolYearName ?? '—'}
+
+
+
Current balance
+
${formatMoney(statement?.current_balance)}
+
+
+ +
+ Opening balance from prior year: ${formatMoney(statement?.opening_balance)}. The old-balance invoice is the collectible item; this opening-balance label is audit context, not a second charge. +
+ +
+ + + + + + + + + + + {lines.length === 0 ? ( + + ) : lines.map((line, index) => ( + + + + + + + ))} + +
DescriptionSource yearInvoiceAmount
No statement lines found.
{line.label}{line.source_school_year ?? '—'}{line.invoice_id ?? '—'}${formatMoney(line.amount)}
+
+
+
+
+ ) +}