9 Commits

Author SHA1 Message Date
root c6d2bc007e fix first production issues
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 40s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 24s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-10 03:43:47 -04:00
root b895c06dc1 fix parent pages and teachers and admin
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-09 13:56:26 -04:00
root 4b78aafa65 fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 30s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-07 01:52:37 -04:00
root b510f534fd security fix and fix parent pages
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 22s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 32s
2026-07-06 02:14:22 -04:00
root b74ddca8b1 add school year and security fix
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 28s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-06 00:55:02 -04:00
root 8cacd0874f fix calendar page
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 32s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 47s
2026-07-05 14:18:49 -04:00
root 405dc3e379 fix(ci): remove GitHub-specific marketplace actions incompatible with Gitea act runner
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 18s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 14s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 15s
- Remove actions/cache@v4 (uses GitHub's artifact API not available in Gitea)
- Remove actions/upload-artifact@v4 (same reason)
- Remove actions/download-artifact@v4 (same reason)
- Deploy job now builds from source instead of downloading artifacts
- Deploy container changed from alpine:3.20 to node:22-alpine for npm build
2026-06-22 00:57:53 -04:00
root 816f4cfaf0 fix: resolve all CI lint errors (9 errors → 0)
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 40s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 36s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
- Move SortTh outside AdminEmergencyContactIndexPage to fix
  react-hooks/static-components (5 errors)
- Fix ternary expressions used as statements in toggleSection
  and toggleDate (2 @typescript-eslint/no-unused-expressions errors)
- Remove stale eslint-disable-next-line react/no-danger comment
  referencing unregistered rule (1 error)
- Fix pre-existing any type in WiredParentScreens student.map
2026-06-22 00:38:47 -04:00
root 7282020444 chore: configure eslint rules and fix lint warnings
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 41s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
- Add eslint rules section downgrading strict react-hooks v7 rules to warn
- Remove unnecessary Boolean() wrappers across multiple pages
- Fix let→const for non-reassigned variable in AdminProgressPages
- Clean up stale eslint-disable-next-line comment and trailing whitespace
- Simplify setTimeout declaration in ManualPayPage
2026-06-22 00:17:18 -04:00
56 changed files with 3711 additions and 1339 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ After pushing, the pipeline will run automatically. The following steps are requ
| `SSH_PORT` | `22` | SSH port for the deployment server |
| `SSH_TARGET_DIR` | — | Absolute path on the server where `dist/` contents should be placed |
| `PRODUCTION_URL` | — | Public URL of the production site |
| `VITE_API_ORIGIN` | — | (Optional) API base URL passed at build time |
| `VITE_API_URL` | — | (Optional) API base URL passed at build time |
## 3. Allow Gitea Actions
+10 -31
View File
@@ -44,14 +44,6 @@ jobs:
git checkout "${{ github.sha }}"
shell: sh
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: node_modules/
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci --no-audit --no-fund
@@ -82,28 +74,13 @@ jobs:
git checkout "${{ github.sha }}"
shell: sh
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: node_modules/
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_ORIGIN: ${{ vars.VITE_API_ORIGIN || '' }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
# ──────────────────────────────────────────────
# DEPLOY (manual): to shared hosting via SSH
@@ -112,7 +89,7 @@ jobs:
name: Deploy to shared hosting
runs-on: ubuntu-latest
container:
image: alpine:3.20
image: node:22-alpine
if: github.ref == 'refs/heads/develop'
needs: [lint, build]
environment:
@@ -134,16 +111,18 @@ jobs:
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
- name: Install SSH and rsync
run: |
apk add --no-cache openssh-client sshpass rsync
- name: Download built artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy built files to shared hosting
run: |
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
+2 -2
View File
@@ -19,8 +19,8 @@ RUN npm install --no-audit --no-fund
COPY . .
ARG VITE_API_ORIGIN=
ENV VITE_API_ORIGIN=$VITE_API_ORIGIN
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
+1 -1
View File
@@ -21,7 +21,7 @@ services:
context: .
dockerfile: Dockerfile
args:
VITE_API_ORIGIN: ${VITE_API_ORIGIN:-}
VITE_API_URL: ${VITE_API_URL:-}
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
ports:
- '8080:80'
+12
View File
@@ -19,5 +19,17 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// ── New strict rules from react-hooks v7 — downgrade to warn ──
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
'react-hooks/immutability': 'warn',
'react-refresh/only-export-components': 'warn',
// ── Keep these as errors (real bugs / anti-patterns) ──
'react-hooks/static-components': 'error',
'@typescript-eslint/no-unused-expressions': 'error',
},
},
])
+524 -537
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -73,3 +73,31 @@ body {
color: #15654f;
font-weight: 600;
}
.school-year-selector {
max-width: 100%;
}
.school-year-selector .form-select {
min-width: 11rem;
}
.school-year-selector--compact .form-label {
color: inherit;
}
.school-year-selector--compact .form-select {
width: auto;
min-width: 10rem;
}
.navbar-dark .school-year-selector,
.custom-navbar .school-year-selector {
color: inherit;
}
.navbar-dark .school-year-selector .form-label,
.custom-navbar .school-year-selector .form-label {
color: inherit;
opacity: 0.85;
}
+55 -1
View File
@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider'
import { RequireAuth } from './auth/RequireAuth'
import { GlobalTableSorting } from './components/GlobalTableSorting'
import { SchoolYearProvider } from './context/SchoolYearContext'
import './App.css'
const PublicLayout = lazy(() =>
@@ -82,6 +83,21 @@ const SchoolYearsManagementPage = lazy(() =>
default: m.SchoolYearsManagementPage,
})),
)
const SchoolYearDetailPage = lazy(() =>
import('./pages/administrator/SchoolYearDetailPage').then((m) => ({
default: m.SchoolYearDetailPage,
})),
)
const SchoolYearClosingReportPage = lazy(() =>
import('./pages/administrator/SchoolYearReportsPage').then((m) => ({
default: () => <m.SchoolYearReportsPage reportType="closing" />,
})),
)
const SchoolYearBalanceTransfersReportPage = lazy(() =>
import('./pages/administrator/SchoolYearReportsPage').then((m) => ({
default: () => <m.SchoolYearReportsPage reportType="balance-transfers" />,
})),
)
const NavBuilderPage = lazy(() =>
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
)
@@ -479,6 +495,20 @@ const InventoryMovementFormPage = lazy(() =>
default: m.InventoryMovementFormPage,
})),
)
const InventoryDashboardPage = lazy(() =>
import('./pages/inventory/InventoryDashboardPage').then((m) => ({ default: m.InventoryDashboardPage })),
)
const InventoryLowStockPage = lazy(() =>
import('./pages/inventory/InventoryLowStockPage').then((m) => ({ default: m.InventoryLowStockPage })),
)
const InventoryReorderSuggestionsPage = lazy(() =>
import('./pages/inventory/InventoryReorderSuggestionsPage').then((m) => ({
default: m.InventoryReorderSuggestionsPage,
})),
)
const InventoryStockStatusPage = lazy(() =>
import('./pages/inventory/InventoryStockStatusPage').then((m) => ({ default: m.InventoryStockStatusPage })),
)
const TeacherBookDistributePage = lazy(() =>
import('./pages/inventory/TeacherBookDistributePage').then((m) => ({
default: m.TeacherBookDistributePage,
@@ -702,7 +732,7 @@ const FamilyComposeEmailPage = lazy(() =>
})),
)
const FamilyLegacyImportPage = lazy(() =>
import('./pages/family/FamilyLegacyImportPage').then((m) => ({ default: m.FamilyLegacyImportPage })),
import('./pages/FamiliesImportLegacyPage').then((m) => ({ default: m.FamiliesImportLegacyPage })),
)
const EmailTemplatesIndexPage = lazy(() =>
import('./pages/emails').then((m) => ({ default: m.EmailTemplatesIndexPage })),
@@ -992,6 +1022,7 @@ export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<SchoolYearProvider>
<GlobalTableSorting />
<Suspense fallback={<RouteFallback />}>
<Routes>
@@ -1132,6 +1163,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/summary" element={<SchoolYearDetailPage />} />
<Route
path="admin/school-years/reports/closing"
element={<SchoolYearClosingReportPage />}
/>
<Route
path="admin/school-years/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 />} />
@@ -1204,6 +1246,16 @@ export default function App() {
<Route path="grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
<Route path="grading/main" element={<GradingMainPage />} />
<Route path="grading" element={<GradingMainPage />} />
<Route path="administrator/inventory" element={<InventoryDashboardPage />} />
<Route path="administrator/inventory/dashboard" element={<InventoryDashboardPage />} />
<Route path="inventory" element={<InventoryDashboardPage />} />
<Route path="inventory/dashboard" element={<InventoryDashboardPage />} />
<Route path="administrator/inventory/low-stock" element={<InventoryLowStockPage />} />
<Route path="administrator/inventory/reorder-suggestions" element={<InventoryReorderSuggestionsPage />} />
<Route path="administrator/inventory/stock-status" element={<InventoryStockStatusPage />} />
<Route path="inventory/low-stock" element={<InventoryLowStockPage />} />
<Route path="inventory/reorder-suggestions" element={<InventoryReorderSuggestionsPage />} />
<Route path="inventory/stock-status" element={<InventoryStockStatusPage />} />
<Route path="administrator/inventory/book" element={<InventoryBookIndexPage />} />
<Route path="administrator/inventory/book/create" element={<InventoryBookFormPage />} />
<Route path="administrator/inventory/book/:itemId/edit" element={<InventoryBookFormPage />} />
@@ -1245,6 +1297,7 @@ export default function App() {
<Route path="notifications/active" element={<NotificationsActivePage />} />
<Route path="administrator/notifications/deleted" element={<NotificationsDeletedPage />} />
<Route path="notifications/deleted" element={<NotificationsDeletedPage />} />
<Route path="administrator/tuition-forecast" element={<FinancialReportSummaryPage />} />
<Route path="administrator/payment/manual-pay" element={<ManualPayPage />} />
<Route
path="administrator/payment/manual-payment"
@@ -1513,6 +1566,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</SchoolYearProvider>
</BrowserRouter>
</AuthProvider>
)
+2 -1
View File
@@ -1,5 +1,5 @@
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
import type { ApiEnvelope } from './types'
export type CertificateDecisionRow = {
@@ -74,6 +74,7 @@ function authHeaders(accept: string) {
const headers = new Headers({ Accept: accept })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
return headers
}
+5 -5
View File
@@ -216,7 +216,7 @@ export async function fetchBelowSixtyEmailEditor(searchParams: URLSearchParams):
subject?: string
html?: string
}> {
return apiFetch(`${BASE}/below-sixty/email-editor${q(searchParams)}`)
return apiFetch(`${BASE}/below-sixty/email${q(searchParams)}`)
}
export async function postBelowSixtyEmail(body: Record<string, unknown>): Promise<unknown> {
@@ -278,14 +278,14 @@ export async function postPlacementSection(body: Record<string, unknown>): Promi
}
export async function fetchPlacementBatch(batchId: string | number): Promise<unknown> {
return apiFetch(`${BASE}/placement-batch/${batchId}`)
return apiFetch(`${BASE}/placement/batch/${batchId}`)
}
export async function postPlacementBatch(
batchId: string | number,
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/placement-batch/${batchId}`, {
return apiFetch(`${BASE}/placement/batch/${batchId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -295,11 +295,11 @@ export async function postPlacementBatch(
export async function fetchScheduleMeetingForm(
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/schedule-meeting${q(searchParams)}`)
return apiFetch(`${BASE}/below-sixty/meeting${q(searchParams)}`)
}
export async function postScheduleMeeting(body: Record<string, unknown>): Promise<unknown> {
return apiFetch(`${BASE}/schedule-meeting`, {
return apiFetch(`${BASE}/below-sixty/meeting`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
+20 -2
View File
@@ -1,4 +1,5 @@
import { apiUrl } from '../lib/apiOrigin'
import { selectedSchoolYearHeaders, withSelectedSchoolYearUrl } from '../lib/schoolYearSelection'
const TOKEN_KEY = 'alrahma_api_token'
@@ -30,6 +31,18 @@ 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 function withStoredSchoolYearUrl(url: string): string {
return withSelectedSchoolYearUrl(url)
}
export class ApiHttpError extends Error {
readonly status: number
readonly body: unknown
@@ -60,7 +73,12 @@ export async function apiFetch<T>(
headers.set('Authorization', `Bearer ${token}`)
}
const url = apiUrl(path)
if (attachAuth && token) {
applyStoredSchoolYearHeaders(headers)
}
const method = String(init.method ?? 'GET').toUpperCase()
const url = apiUrl(method === 'GET' ? withStoredSchoolYearUrl(path) : path)
let res: Response
@@ -71,7 +89,7 @@ export async function apiFetch<T>(
})
} catch (e) {
const hint = import.meta.env.DEV
? ' Start the API and ensure Vite can reach it. Default proxy is http://localhost:8000 unless VITE_PROXY_API or VITE_API_ORIGIN is set.'
? ' Start the API and ensure Vite can reach it. Default proxy is http://localhost:8000 unless VITE_PROXY_API or VITE_API_URL is set.'
: ''
const reason = e instanceof Error ? e.message : 'Failed to fetch'
+24 -15
View File
@@ -11,6 +11,13 @@ function q(searchParams: URLSearchParams): string {
return s ? `?${s}` : ''
}
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type InventoryKind = 'book' | 'classroom' | 'kitchen' | 'office'
/** Index list: expect `{ items, categories, userNames }` (names optional). */
@@ -20,11 +27,11 @@ export async function fetchInventoryIndex(
): Promise<unknown> {
const params = new URLSearchParams(searchParams)
params.set('type', kind)
return apiFetch(`${BASE}/items?${params.toString()}`)
return unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`))
}
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
return apiFetch(`${BASE}/category`, {
return apiFetch(`${BASE}/categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -37,7 +44,7 @@ export async function fetchInventoryItem(itemId: string | number): Promise<{
conditionOptions?: Record<string, string>
[key: string]: unknown
}> {
return apiFetch(`${BASE}/items/${itemId}`)
return unwrapData(await apiFetch(`${BASE}/items/${itemId}`))
}
/** Create form payload (categories, defaults). */
@@ -45,7 +52,9 @@ export async function fetchInventoryCreateForm(
kind: InventoryKind,
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/${kind}/create${q(searchParams)}`)
const params = new URLSearchParams(searchParams)
params.set('type', kind)
return unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`))
}
export async function postInventoryItem(body: Record<string, unknown>): Promise<unknown> {
@@ -61,7 +70,7 @@ export async function putInventoryItem(
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/items/${itemId}`, {
method: 'PUT',
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
@@ -86,7 +95,7 @@ export async function postClassroomAudit(
itemId: string | number,
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/items/${itemId}/classroom-audit`, {
return apiFetch(`${BASE}/items/${itemId}/audit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -98,11 +107,11 @@ export async function fetchInventorySummary(searchParams: URLSearchParams): Prom
const params = new URLSearchParams(searchParams)
params.delete('type')
const qs = params.toString()
return apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`)
return unwrapData(await apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`))
}
export async function fetchMovements(searchParams: URLSearchParams): Promise<unknown> {
return apiFetch(`${BASE}/movements${q(searchParams)}`)
return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`))
}
export async function fetchMovementForm(
@@ -110,9 +119,9 @@ export async function fetchMovementForm(
searchParams: URLSearchParams,
): Promise<unknown> {
if (movementId === undefined || movementId === '') {
return apiFetch(`${BASE}/movements/create${q(searchParams)}`)
return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`))
}
return apiFetch(`${BASE}/movements/${movementId}/edit${q(searchParams)}`)
return unwrapData(await apiFetch(`${BASE}/movements/${movementId}/edit${q(searchParams)}`))
}
export async function postMovement(body: Record<string, unknown>): Promise<unknown> {
@@ -128,7 +137,7 @@ export async function putMovement(
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/movements/${movementId}`, {
method: 'PUT',
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
@@ -167,26 +176,26 @@ export async function postTeacherBookDistribute(body: Record<string, unknown>):
/** Fetch low-stock items (quantity <= reorder_point). */
export async function fetchLowStock(): Promise<unknown> {
return apiFetch(`${BASE}/low-stock`)
return unwrapData(await apiFetch(`${BASE}/low-stock`))
}
/** Fetch reorder suggestions for low-stock items. */
export async function fetchReorderSuggestions(): Promise<unknown> {
return apiFetch(`${BASE}/reorder-suggestions`)
return unwrapData(await apiFetch(`${BASE}/reorder-suggestions`))
}
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
export async function fetchStockStatus(
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/stock-status${q(searchParams)}`)
return unwrapData(await apiFetch(`${BASE}/stock-status${q(searchParams)}`))
}
/** Fetch inventory dashboard summary counts. */
export async function fetchInventoryDashboard(
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/dashboard${q(searchParams)}`)
return unwrapData(await apiFetch(`${BASE}/dashboard${q(searchParams)}`))
}
/** Void a movement (append-only correction with reverse entry). */
+6 -7
View File
@@ -2,7 +2,7 @@
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
*/
import { apiUrl } from '../lib/apiOrigin'
import { apiFetch, getStoredToken } from './http'
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
const BASE = '/api/v1/finance/invoices'
@@ -50,12 +50,11 @@ export async function fetchInvoicePreview(invoiceId: string | number): Promise<u
/** Binary PDF with JWT (for tabs that cannot send Authorization). */
export async function fetchInvoicePdfBlob(invoiceId: string | number): Promise<Blob> {
const token = getStoredToken()
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/pdf',
},
})
const headers = new Headers({ Accept: 'application/pdf' })
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), { headers })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `PDF HTTP ${res.status}`)
+70
View File
@@ -0,0 +1,70 @@
import { apiFetch } from './http'
export type ParentProfileSummary = {
id: number
firstname?: string | null
lastname?: string | null
email?: string | null
cellphone?: string | null
is_active?: boolean
families_count?: number
selected_year_students_count?: number
balance?: number
positive_unpaid_balance?: number
credit_balance?: number
primary_family?: { id?: number; household_name?: string | null } | null
}
export type ParentProfileDetail = {
school_year: string
read_only: boolean
parent: Record<string, unknown> | null
families: Record<string, unknown>[]
students: Record<string, unknown>[]
emergency_contacts: Record<string, unknown>[]
invoices: Record<string, unknown>[]
payments: Record<string, unknown>[]
finance_summary: Record<string, unknown>
}
type Envelope<T> = { data?: T }
function unwrap<T>(response: T | Envelope<T>): T {
if (response && typeof response === 'object' && 'data' in response) {
return (response as Envelope<T>).data as T
}
return response as T
}
export async function fetchParentProfiles(params: {
schoolYear: string
q?: string
page?: number
perPage?: number
}): Promise<{
school_year: string
read_only: boolean
parents: ParentProfileSummary[]
pagination: { page: number; per_page: number; total: number }
}> {
const query = new URLSearchParams()
query.set('school_year', params.schoolYear)
if (params.q?.trim()) query.set('q', params.q.trim())
if (params.page) query.set('page', String(params.page))
if (params.perPage) query.set('per_page', String(params.perPage))
return unwrap(await apiFetch(`/api/v1/parent-profiles?${query.toString()}`))
}
export async function fetchParentProfile(parentId: number, schoolYear: string): Promise<ParentProfileDetail> {
const query = new URLSearchParams({ school_year: schoolYear })
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`))
}
export async function updateParentProfile(parentId: number, schoolYear: string, payload: Record<string, unknown>): Promise<ParentProfileDetail> {
const query = new URLSearchParams({ school_year: schoolYear })
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, school_year: schoolYear }),
}))
}
+5 -1
View File
@@ -3,7 +3,7 @@
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type AdminNotificationRow = {
id?: number
@@ -56,6 +56,7 @@ async function postMultipart<T>(path: string, formData: FormData): Promise<T> {
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
@@ -136,6 +137,7 @@ export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string
const headers = new Headers({ Accept: '*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(apiPath), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -335,6 +337,7 @@ export async function downloadFinancialReportCsv(params: {
const headers = new Headers()
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -408,6 +411,7 @@ export async function downloadFinancialSummaryPdf(params: { school_year?: string
const headers = new Headers()
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const raw = await res.blob()
+13 -4
View File
@@ -2,10 +2,17 @@
* 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>
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type TeacherPrintRequestsContext = {
sundays?: string[]
times?: string[]
@@ -23,6 +30,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) {
@@ -60,15 +68,15 @@ export async function deletePrintRequest(requestId: number | string): Promise<{
}
export async function fetchAdminPrintRequests(): Promise<{ print_requests: PrintRequestRow[] }> {
return apiFetch('/api/v1/administrator/print-requests')
return unwrapData(await apiFetch('/api/v1/print-requests/admin'))
}
export async function adminUpdatePrintRequestStatus(
requestId: number | string,
payload: { status: string; admin_id?: number | string },
): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`/api/v1/administrator/print-requests/${encodeURIComponent(String(requestId))}/status`, {
method: 'POST',
return apiFetch(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
@@ -83,6 +91,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()
+103 -23
View File
@@ -2,7 +2,7 @@
* Stickers, badges, report cards (legacy CI printables_reports/*).
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type ClassOption = { class_section_id?: number; id?: number; class_section_name?: string }
@@ -25,22 +25,31 @@ export async function fetchStickerFormOptions(params?: {
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`/api/v1/administrator/printables/stickers/form-options${suffix}`)
const body = await apiFetch<{
ok?: boolean
data?: { classes?: ClassOption[]; students?: StudentOption[] }
classes?: ClassOption[]
students?: StudentOption[]
}>(`/api/v1/reports/stickers/form-data${suffix}`)
return body.data ?? body
}
export async function fetchStudentsByClass(classId: number | string): Promise<StudentOption[]> {
const body = await apiFetch<{ students?: StudentOption[] } | StudentOption[]>(
`/api/v1/administrator/students/by-class/${encodeURIComponent(String(classId))}`,
)
if (Array.isArray(body)) return body
return body.students ?? []
const qs = new URLSearchParams({ class_section_id: String(classId) })
const body = await apiFetch<{
ok?: boolean
data?: { students?: StudentOption[] }
students?: StudentOption[]
}>(`/api/v1/reports/stickers/students?${qs}`)
return body.data?.students ?? body.students ?? []
}
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}`)
const res = await fetch(apiUrl('/api/v1/administrator/printables/stickers/generate'), {
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl('/api/v1/reports/stickers/print'), {
method: 'POST',
headers,
body: formData,
@@ -61,6 +70,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,
@@ -89,7 +99,7 @@ export async function fetchStickerPreviewCounts(classId?: number | string | null
const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<
StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts }
>(`/api/v1/administrator/printables/stickers/preview${suffix}`)
>(`/api/v1/reports/stickers/preview${suffix}`)
if (body.data) return body.data
return body
}
@@ -170,7 +180,8 @@ export async function fetchReportCardMeta(params?: {
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`/api/v1/administrator/printables/report-card/meta${suffix}`)
const body = await apiFetch<ReportCardMeta & { data?: ReportCardMeta }>(`/api/v1/administrator/printables/report-card/meta${suffix}`)
return body.data ?? body
}
export async function fetchReportCardAck(params: {
@@ -182,9 +193,11 @@ export async function fetchReportCardAck(params: {
qs.set('student_id', String(params.student_id))
if (params.school_year) qs.set('school_year', params.school_year)
if (params.semester) qs.set('semester', params.semester ?? '')
return apiFetch(`/api/v1/administrator/printables/report-card/ack?${qs}`)
const body = await apiFetch<{ data?: { viewed_at?: string; signed_at?: string; signed_name?: string }; viewed_at?: string; signed_at?: string; signed_name?: string }>(`/api/v1/administrator/printables/report-card/ack?${qs}`)
return body.data ?? body
}
export async function fetchReportCardCompleteness(params: {
class_section_id: number | string
school_year?: string
@@ -199,11 +212,17 @@ export async function fetchReportCardCompleteness(params: {
qs.set('class_section_id', String(params.class_section_id))
if (params.school_year) qs.set('school_year', params.school_year ?? '')
if (params.semester) qs.set('semester', params.semester ?? '')
return apiFetch(`/api/v1/administrator/printables/report-card/completeness?${qs}`)
const body = await apiFetch<{
data?: { students?: Array<Record<string, unknown>>; summary?: Record<string, unknown>; used_fallback?: boolean }
students?: Array<Record<string, unknown>>
summary?: Record<string, unknown>
used_fallback?: boolean
}>(`/api/v1/administrator/printables/report-card/completeness?${qs}`)
return body.data ?? body
}
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
export function reportCardStudentUrl(
function reportCardStudentPath(
studentId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
@@ -213,10 +232,10 @@ export function reportCardStudentUrl(
if (opts.download) qs.set('download', '1')
if (opts.report_date) qs.set('report_date', opts.report_date)
qs.set('cb', String(Date.now()))
return apiUrl(`/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`)
return `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
}
export function reportCardClassUrl(
function reportCardClassPath(
classSectionId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
@@ -226,7 +245,22 @@ export function reportCardClassUrl(
if (opts.download) qs.set('download', '1')
if (opts.report_date) qs.set('report_date', opts.report_date)
qs.set('cb', String(Date.now()))
return apiUrl(`/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`)
return `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
}
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
export function reportCardStudentUrl(
studentId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
return apiUrl(reportCardStudentPath(studentId, opts))
}
export function reportCardClassUrl(
classSectionId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
return apiUrl(reportCardClassPath(classSectionId, opts))
}
/** HTML document for iframe preview (Bearer auth; unlike raw iframe src). */
@@ -242,6 +276,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,20 +295,65 @@ 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)
return res.text()
}
/** Opens PDF download in new tab (authenticated GET). */
export async function openReportCardDownload(url: string): Promise<void> {
async function fetchReportCardPdf(path: string): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (!token) {
throw new ApiHttpError('Your session has expired. Please sign in again.', 401, null)
}
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) {
const errBody: unknown = await res.json().catch(() => null)
const msg =
typeof errBody === 'object' && errBody !== null && 'message' in errBody
? String((errBody as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody)
}
const raw = await res.blob()
const blob = new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
}
function openBlob(blob: Blob): void {
const objectUrl = URL.createObjectURL(blob)
window.open(objectUrl, '_blank', 'noopener')
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
}
/** Opens PDF download in new tab (authenticated GET). */
export async function openReportCardStudentDownload(
studentId: number | string,
opts: { school_year: string; semester?: string; report_date?: string },
): Promise<void> {
openBlob(
await fetchReportCardPdf(
reportCardStudentPath(studentId, {
...opts,
download: true,
}),
),
)
}
export async function openReportCardClassDownload(
classSectionId: number | string,
opts: { school_year: string; semester?: string; report_date?: string },
): Promise<void> {
openBlob(
await fetchReportCardPdf(
reportCardClassPath(classSectionId, {
...opts,
download: true,
}),
),
)
}
+12 -7
View File
@@ -3,7 +3,7 @@
* Backend is expected under `/api/v1/administrator/reimbursements`.
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
const BASE = '/api/v1/administrator/reimbursements'
@@ -58,6 +58,7 @@ export async function downloadReimbursementsExport(params?: {
const headers = new Headers({ Accept: 'text/csv,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -66,10 +67,11 @@ export async function downloadReimbursementsExport(params?: {
export async function downloadBatchCsv(batchId: number | string): Promise<void> {
const qs = new URLSearchParams({ batch_id: String(batchId) })
const url = apiUrl(`${BASE}/batch/export?${qs}`)
const url = apiUrl(`${BASE}/batches/export?${qs}`)
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,7 +82,8 @@ 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}`)
const res = await fetch(apiUrl(`${BASE}/batch/send`), { method: 'POST', headers, body: formData })
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`${BASE}/batches/email`), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
const msg =
@@ -111,13 +114,14 @@ export async function createReimbursementBatch(): Promise<{
csrf_hash?: string
}> {
const fd = new FormData()
return postFormExpectJson(`${BASE}/batch/create`, fd)
return postFormExpectJson(`${BASE}/batches`, fd)
}
async function postFormExpectJson(path: string, form: FormData): Promise<{ success?: boolean; error?: string; csrf_hash?: string; reimbursement_id?: number } & Record<string, unknown>> {
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>
@@ -131,15 +135,15 @@ async function postFormExpectJson(path: string, form: FormData): Promise<{ succe
}
export function postBatchUpdate(form: FormData) {
return postFormExpectJson(`${BASE}/batch/update`, form)
return postFormExpectJson(`${BASE}/batches/assign`, form)
}
export function postBatchLock(form: FormData) {
return postFormExpectJson(`${BASE}/batch/lock`, form)
return postFormExpectJson(`${BASE}/batches/lock`, form)
}
export function postAdminCheckUpload(form: FormData) {
return postFormExpectJson(`${BASE}/batch/admin-file/upload`, form)
return postFormExpectJson(`${BASE}/batches/admin-files`, form)
}
export function postMarkDonation(form: FormData) {
@@ -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) {
+188 -23
View File
@@ -9,6 +9,7 @@ export type SchoolYearRecord = {
end_date: string
status: SchoolYearStatus
is_current: boolean
isCurrent?: boolean
closed_at?: string | null
closed_by?: number | null
}
@@ -19,7 +20,11 @@ export type SchoolYearSummary = {
students_considered: number
students_blocked: number
parents_with_balances: number
parents_with_positive_balance?: number
net_balance_to_transfer: number
net_balance_impact?: number
total_positive_unpaid_balance?: number
total_credit_balance?: number
}
}
@@ -56,19 +61,29 @@ export type SchoolYearParentBalanceRow = {
parent_name?: string | null
student_names?: string[]
old_unpaid_balance: number
positive_unpaid_balance?: number
credit_balance: number
net_balance?: number
amount_to_transfer: number
positive_invoice_ids?: number[]
credit_invoice_ids?: number[]
source_invoice_ids?: number[]
existing_transfer_conflict?: boolean
}
export type SchoolYearParentBalancePreview = {
from_school_year?: string
to_school_year?: string
rows: SchoolYearParentBalanceRow[]
summary: {
parents_with_non_zero_balance: number
parents_with_positive_balance?: number
parents_with_credit_balance: number
total_old_unpaid_balance: number
total_positive_unpaid_balance?: number
total_credit_balance: number
net_balance_to_transfer: number
net_balance_impact?: number
existing_transfer_conflicts: number
}
}
@@ -95,6 +110,67 @@ 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_carried_or_reported?: number
total_credit_transferred?: number
net_balance_impact?: 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 +179,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 +232,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 ?? []
@@ -167,14 +267,17 @@ export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise<
}
export async function updateSchoolYear(
schoolYearId: number,
schoolYear: string,
payload: SaveSchoolYearPayload,
): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}`, {
const response = await apiFetch<ApiRecordResponse>(
`/api/v1/school-years/selected${selectedYearQuery(schoolYear)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.data) {
throw new Error('School year update returned no record.')
@@ -183,8 +286,23 @@ export async function updateSchoolYear(
return response.data
}
export async function fetchSchoolYearSummary(schoolYearId: number): Promise<SchoolYearSummary> {
const response = await apiFetch<ApiSummaryResponse>(`/api/v1/school-years/${schoolYearId}/summary`)
function selectedYearQuery(schoolYear: string, extras?: Record<string, string | undefined>): string {
const query = new URLSearchParams()
query.set('school_year', schoolYear)
for (const [key, value] of Object.entries(extras ?? {})) {
if (value) query.set(key, value)
}
return `?${query.toString()}`
}
function selectedYearBody(payload: object, schoolYear: string): string {
return JSON.stringify({ ...payload, school_year: schoolYear })
}
export async function fetchSchoolYearSummary(schoolYear: string): Promise<SchoolYearSummary> {
const response = await apiFetch<ApiSummaryResponse>(
`/api/v1/school-years/summary${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year summary returned no data.')
}
@@ -192,15 +310,15 @@ export async function fetchSchoolYearSummary(schoolYearId: number): Promise<Scho
}
export async function validateSchoolYearClose(
schoolYearId: number,
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseValidation> {
const response = await apiFetch<ApiValidationResponse>(
`/api/v1/school-years/${schoolYearId}/validate-close`,
`/api/v1/school-years/validate-close${selectedYearQuery(schoolYear)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: selectedYearBody(payload, schoolYear),
},
)
@@ -212,15 +330,15 @@ export async function validateSchoolYearClose(
}
export async function previewSchoolYearClose(
schoolYearId: number,
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearClosePreview> {
const response = await apiFetch<ApiPreviewResponse>(
`/api/v1/school-years/${schoolYearId}/preview-close`,
`/api/v1/school-years/preview-close${selectedYearQuery(schoolYear)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: selectedYearBody(payload, schoolYear),
},
)
@@ -232,13 +350,13 @@ export async function previewSchoolYearClose(
}
export async function closeSchoolYear(
schoolYearId: number,
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseResult> {
const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/${schoolYearId}/close`, {
const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/close${selectedYearQuery(schoolYear)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: selectedYearBody(payload, schoolYear),
})
if (!response.data) {
@@ -248,10 +366,11 @@ export async function closeSchoolYear(
return response.data
}
export async function reopenSchoolYear(schoolYearId: number): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}/reopen`, {
export async function reopenSchoolYear(schoolYear: string): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/reopen${selectedYearQuery(schoolYear)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ school_year: schoolYear }),
})
if (!response.data) {
@@ -262,12 +381,11 @@ export async function reopenSchoolYear(schoolYearId: number): Promise<SchoolYear
}
export async function fetchSchoolYearParentBalances(
schoolYearId: number,
schoolYear: string,
toSchoolYear?: string,
): Promise<SchoolYearParentBalancePreview> {
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
const response = await apiFetch<ApiParentBalancesResponse>(
`/api/v1/school-years/${schoolYearId}/parent-balances${query}`,
`/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
)
if (!response.data) {
@@ -278,12 +396,11 @@ export async function fetchSchoolYearParentBalances(
}
export async function fetchSchoolYearPromotionPreview(
schoolYearId: number,
schoolYear: string,
toSchoolYear?: string,
): Promise<SchoolYearPromotionPreview> {
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
const response = await apiFetch<ApiPromotionPreviewResponse>(
`/api/v1/school-years/${schoolYearId}/promotion-preview${query}`,
`/api/v1/school-years/promotion-preview${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
)
if (!response.data) {
@@ -292,3 +409,51 @@ export async function fetchSchoolYearPromotionPreview(
return response.data
}
export async function fetchSchoolYearClosingReport(
schoolYear: string,
): Promise<SchoolYearClosingReport> {
const response = await apiFetch<ApiClosingReportResponse>(
`/api/v1/school-years/reports/closing${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year closing report returned no payload.')
}
return response.data
}
export async function fetchSchoolYearBalanceTransferReport(
schoolYear: string,
): Promise<SchoolYearBalanceTransferReport> {
const response = await apiFetch<ApiBalanceTransferReportResponse>(
`/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year balance transfer report returned no payload.')
}
return response.data
}
export async function fetchParentStatement(params: {
schoolYear?: string | null
parentId?: number | null
}): Promise<ParentStatement> {
const query = new URLSearchParams()
if (params.schoolYear) query.set('school_year', params.schoolYear)
if (params.parentId != null) query.set('parent_id', String(params.parentId))
const response = await apiFetch<ApiParentStatementResponse>(
`/api/v1/parents/statements${query.toString() ? `?${query}` : ''}`,
)
if (!response.data) {
throw new Error('Parent statement returned no payload.')
}
return response.data
}
+35 -5
View File
@@ -1,5 +1,5 @@
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
import type {
AdministratorAbsenceFormResponse,
AdministratorAbsenceSubmitResponse,
@@ -32,6 +32,7 @@ import type {
NavMenuResponse,
ParentAttendanceResponse,
ParentAttendanceReportFormResponse,
ParentEnrollmentActionResponse,
ParentEnrollmentsResponse,
ParentEventsOverviewResponse,
ParentInvoicesResponse,
@@ -595,6 +596,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 +1226,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 +1258,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,
@@ -1718,10 +1722,15 @@ export async function sendFamilyComposeEmail(payload: {
html: string
return_url?: string
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`/api/v1/family-admin/compose-email/send`, {
return apiFetch(`/api/v1/family-admin/compose-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify({
recipient: payload.to.trim(),
subject: payload.subject.trim(),
html_message: payload.html,
return_url: payload.return_url,
}),
})
}
@@ -1781,17 +1790,18 @@ export async function fetchPaypalTransactions(params?: {
if (params?.q) query.set('q', params.q)
if (params?.perPage) query.set('per_page', String(params.perPage))
const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<PaypalTransactionsResponse>(`/api/v1/paypal-transactions${qs}`)
return apiFetch<PaypalTransactionsResponse>(`/api/v1/administrator/paypal-transactions${qs}`)
}
export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promise<Blob> {
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()}` : ''
const res = await fetch(apiUrl(`/api/v1/paypal-transactions/csv${suffix}`), { headers })
const res = await fetch(apiUrl(`/api/v1/administrator/paypal-transactions/csv${suffix}`), { headers })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.blob()
}
@@ -1854,6 +1864,17 @@ export async function fetchParentEnrollments(
return apiFetch<ParentEnrollmentsResponse>(`/api/v1/parents/enrollments${q}`)
}
export async function updateParentEnrollments(payload: {
enroll: number[]
withdraw: number[]
}): Promise<ParentEnrollmentActionResponse> {
return apiFetch<ParentEnrollmentActionResponse>('/api/v1/parents/enrollments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function fetchParentInvoices(
schoolYear: string | null,
): Promise<ParentInvoicesResponse> {
@@ -1953,6 +1974,8 @@ export async function fetchClassProgressGroups(params?: {
weekStart?: string | null
weekEnd?: string | null
subject?: string | null
schoolYear?: string | null
semester?: string | null
page?: number
perPage?: number
}): Promise<ClassProgressGroupsResponse> {
@@ -1963,6 +1986,8 @@ export async function fetchClassProgressGroups(params?: {
if (params?.weekStart) query.set('week_start', params.weekStart)
if (params?.weekEnd) query.set('week_end', params.weekEnd)
if (params?.subject) query.set('subject', params.subject)
if (params?.schoolYear) query.set('school_year', params.schoolYear)
if (params?.semester) query.set('semester', params.semester)
if (params?.page) query.set('page', String(params.page))
if (params?.perPage) query.set('per_page', String(params.perPage))
return apiFetch<ClassProgressGroupsResponse>(`/api/v1/class-progress?${query.toString()}`)
@@ -1975,10 +2000,14 @@ export async function fetchClassProgressDetail(id: number): Promise<ClassProgres
export async function fetchClassProgressMeta(params?: {
classId?: number | null
sundayCount?: number | null
schoolYear?: string | null
semester?: string | null
}): Promise<ClassProgressMetaResponse> {
const query = new URLSearchParams()
if (params?.classId) query.set('class_id', String(params.classId))
if (params?.sundayCount) query.set('sunday_count', String(params.sundayCount))
if (params?.schoolYear) query.set('school_year', params.schoolYear)
if (params?.semester) query.set('semester', params.semester)
const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<ClassProgressMetaResponse>(`/api/v1/class-progress/meta${qs}`)
}
@@ -2142,6 +2171,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,
})
+39 -11
View File
@@ -1,10 +1,16 @@
/**
* Staff directory (legacy CI `staff/*`).
* Expected Laravel routes under `/api/v1/administrator/staff`.
* Staff directory.
*/
import { apiFetch } from './http'
const BASE = '/api/v1/administrator/staff'
const BASE = '/api/v1/staff'
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type StaffListRow = {
id: number
@@ -17,6 +23,7 @@ export type StaffListRow = {
active_role?: string | null
class_section?: string | null
verification_issue?: boolean
status?: string | null
}
export type StaffIndexResponse = {
@@ -25,12 +32,16 @@ export type StaffIndexResponse = {
school_year?: string | null
semester?: string | null
schoolYears?: string[]
meta?: unknown
}
export type RoleOption = { id: number; name: string }
export type RoleOption = { id: number; name: string; label?: string }
export type StaffFormOptionsResponse = {
roles?: RoleOption[]
statuses?: Array<{ value: string; label: string }>
school_year?: string | null
is_read_only_year?: boolean
}
export type StaffMemberResponse = {
@@ -52,28 +63,45 @@ export type StaffMemberResponse = {
export async function fetchStaffIndex(params?: {
school_year?: string
semester?: string
role?: string
status?: string
search?: string
page?: number
per_page?: number
}): Promise<StaffIndexResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
if (params?.role) qs.set('role', params.role)
if (params?.status) qs.set('status', params.status)
if (params?.search) qs.set('search', params.search)
if (params?.page) qs.set('page', String(params.page))
if (params?.per_page) qs.set('per_page', String(params.per_page))
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`${BASE}${suffix}`)
return unwrapData(await apiFetch(`${BASE}${suffix}`))
}
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> {
return apiFetch(`${BASE}/form-options`)
export async function fetchStaffFormOptions(params?: { school_year?: string }): Promise<StaffFormOptionsResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
return unwrapData(await apiFetch(`${BASE}/form-options${qs.toString() ? `?${qs}` : ''}`))
}
export async function fetchStaffMember(id: number): Promise<StaffMemberResponse> {
return apiFetch(`${BASE}/${id}`)
export async function fetchStaffMember(id: number, params?: { school_year?: string }): Promise<StaffMemberResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
return unwrapData(await apiFetch(`${BASE}/${id}${qs.toString() ? `?${qs}` : ''}`))
}
export async function createStaff(payload: {
firstname: string
lastname: string
email: string
phone?: string
password: string
role_id: number
status?: string
school_year: string
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}`, {
method: 'POST',
@@ -84,10 +112,10 @@ export async function createStaff(payload: {
export async function updateStaff(
id: number,
payload: { firstname: string; lastname: string; email: string; phone?: string },
payload: { firstname: string; lastname: string; email: string; phone?: string; role_id?: number; status?: string; school_year: string },
): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/${id}`, {
method: 'PUT',
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
+2 -1
View File
@@ -2,7 +2,7 @@
* Teacher portal API (`Views/teacher/*` parity).
* Laravel should expose matching routes under `/api/v1/teacher/...`.
*/
import { apiFetch, getStoredToken } from './http'
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
import { apiUrl } from '../lib/apiOrigin'
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
@@ -47,6 +47,7 @@ export async function postTeacherMultipart<T>(path: string, formData: FormData):
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
+28
View File
@@ -2,6 +2,7 @@ export type AuthUser = {
id: number
name: string
roles: Record<string, boolean>
permissions?: Record<string, boolean> | string[]
class_section_id?: number | null
class_section_name?: string | null
}
@@ -348,9 +349,23 @@ export type ParentProfileResponse = {
export type ParentInvoiceRow = {
id: number
invoice_number?: string | null
total_amount?: number
paid_amount?: number
balance?: number
status?: string | null
due_date?: string | null
last_paid_amount?: number
last_payment_date?: string | null
payments?: Array<{
id: number
invoice_id?: number
paid_amount?: number
balance?: number
payment_method?: string | null
payment_date?: string | null
transaction_id?: string | null
status?: string | null
}>
}
export type ParentInvoicesResponse = {
@@ -376,6 +391,19 @@ export type ParentEnrollmentsResponse = {
students: ParentEnrollmentStudent[]
schoolYears: { school_year: string }[]
selectedYear: string | null
withdrawalDeadline?: string | null
lastDayOfRegistration?: string | null
schoolStartDate?: string | null
}
export type ParentEnrollmentActionResponse = {
ok: true
enrolled: number[]
withdrawn: number[]
data?: {
enrolled?: number[]
withdrawn?: number[]
}
}
export type ContactSubmitResponse = {
+55 -9
View File
@@ -6,6 +6,13 @@ import { apiFetch } from './http'
const BASE = '/api/v1/whatsapp'
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type WhatsappSectionRow = {
class_section_id?: number
id?: number
@@ -16,6 +23,9 @@ export type WhatsappSectionRow = {
}
export type WhatsappLinkRow = {
id?: number
class_section_id?: number
class_section_name?: string
invite_link?: string | null
active?: number | boolean
}
@@ -79,8 +89,29 @@ export async function fetchWhatsappManageLinks(params?: {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
if (!qs.has('per_page')) qs.set('per_page', '200')
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`${BASE}/links${suffix}`)
const body = await apiFetch<WhatsappManageLinksResponse & {
data?: WhatsappManageLinksResponse & { links?: WhatsappLinkRow[] }
links?: WhatsappLinkRow[]
class_sections?: ParentContactsByClassSection[]
}>(`${BASE}/links${suffix}`)
const data = unwrapData(body) as WhatsappManageLinksResponse & {
links?: WhatsappLinkRow[]
class_sections?: ParentContactsByClassSection[]
}
if (!data.sections && data.class_sections) data.sections = data.class_sections
if (!data.linksBySection && Array.isArray(data.links)) {
data.linksBySection = Object.fromEntries(
data.links
.filter((link) => link.class_section_id != null)
.map((link) => [String(link.class_section_id), link]),
)
}
return data
}
export async function saveWhatsappLink(payload: {
@@ -89,11 +120,11 @@ export async function saveWhatsappLink(payload: {
invite_link: string
active?: boolean
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/links`, {
return unwrapData(await apiFetch(`${BASE}/links`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}))
}
export async function sendWhatsappInvites(payload: {
@@ -101,11 +132,11 @@ export async function sendWhatsappInvites(payload: {
class_section_id?: number | null
parent_ids?: number[]
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/invites/send`, {
return unwrapData(await apiFetch(`${BASE}/invites/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}))
}
export async function fetchWhatsappParentContacts(params?: {
@@ -116,11 +147,25 @@ export async function fetchWhatsappParentContacts(params?: {
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`${BASE}/parent-contacts${suffix}`)
const body = await apiFetch<WhatsappParentContactsResponse & { data?: WhatsappParentContactsResponse }>(`${BASE}/parent-contacts${suffix}`)
return unwrapData(body)
}
export async function fetchWhatsappParentContactsByClass(): Promise<WhatsappParentContactsByClassResponse> {
return apiFetch(`${BASE}/parent-contacts-by-class`)
export async function fetchWhatsappParentContactsByClass(params?: {
school_year?: string
semester?: string
}): Promise<WhatsappParentContactsByClassResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<{
data?: { class_sections?: ParentContactsByClassSection[]; classSections?: ParentContactsByClassSection[] }
class_sections?: ParentContactsByClassSection[]
classSections?: ParentContactsByClassSection[]
}>(`${BASE}/parent-contacts-by-class${suffix}`)
const data = unwrapData(body)
return { classSections: data.classSections ?? data.class_sections ?? [] }
}
export async function updateWhatsappMembership(payload: {
@@ -132,9 +177,10 @@ export async function updateWhatsappMembership(payload: {
primary_member?: string
second_member?: string
}): Promise<{ status?: string; message?: string }> {
return apiFetch(`${BASE}/membership`, {
const body = await apiFetch<{ status?: boolean; message?: string; data?: unknown }>(`${BASE}/membership`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { status: body.status ? 'ok' : undefined, message: body.message }
}
@@ -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,52 @@
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,
setSelectedSchoolYearName,
} = 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?.name ?? ''}
disabled={isLoadingSchoolYears || schoolYears.length === 0}
onChange={(event) => {
setSelectedSchoolYearName(event.target.value || null)
}}
>
{isLoadingSchoolYears && schoolYears.length === 0 ? (
<option value="">Loading</option>
) : null}
{schoolYears.map((year) => (
<option key={year.name} value={year.name}>
{year.name}
{isCurrentSchoolYear(year) ? ' Current' : ''}
{year.status && !isCurrentSchoolYear(year) ? ` ${year.status}` : ''}
</option>
))}
</select>
{selectedSchoolYear ? <SchoolYearStatusBadge status={selectedSchoolYear.status} /> : null}
</div>
)
}
@@ -0,0 +1,31 @@
import type { SchoolYearRecord } from '../../api/schoolYears'
function statusBadgeVariant(status: string | null | undefined): string {
switch (String(status ?? '').toLowerCase()) {
case 'active':
return 'success'
case 'closed':
return 'danger'
case 'archived':
return 'dark'
case 'draft':
return 'warning'
default:
return 'secondary'
}
}
export function SchoolYearStatusBadge({
status,
className = '',
}: {
status: SchoolYearRecord['status'] | null | undefined
className?: string
}) {
const label = String(status ?? 'unknown').trim() || 'unknown'
return (
<span className={`badge text-bg-${statusBadgeVariant(status)} ${className}`.trim()}>
{label.toUpperCase()}
</span>
)
}
+4
View File
@@ -0,0 +1,4 @@
export { ReadOnlyBanner } from './ReadOnlyBanner'
export { ReadOnlyGuard } from './ReadOnlyGuard'
export { SchoolYearSelector } from './SchoolYearSelector'
export { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
+164
View File
@@ -0,0 +1,164 @@
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,
getStoredSelectedSchoolYearName,
isCurrentSchoolYear,
isReadOnlySchoolYear,
storeSelectedSchoolYear,
} from '../lib/schoolYearSelection'
type SchoolYearContextValue = {
schoolYears: SchoolYearRecord[]
selectedSchoolYear: SchoolYearRecord | null
selectedSchoolYearName: string | null
activeSchoolYear: SchoolYearRecord | null
isLoadingSchoolYears: boolean
schoolYearError: string | null
isReadOnlyYear: boolean
setSelectedSchoolYearName: (name: string | null) => void
refreshSchoolYears: () => Promise<void>
}
const SchoolYearContext = createContext<SchoolYearContextValue | null>(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
requestedName: string | null
}): SchoolYearRecord | null {
const { years, searchParams, requestedName } = params
if (years.length === 0) return null
const urlName = yearNameFromSearchParams(searchParams)
const storedName = getStoredSelectedSchoolYearName()
const byRequestedName = requestedName ? years.find((year) => year.name === requestedName) : null
if (byRequestedName) return byRequestedName
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 [requestedYearName, setRequestedYearName] = useState<string | 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,
requestedName: requestedYearName,
}),
[requestedYearName, 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 currentName = next.get('school_year')
next.delete('school_year_id')
next.delete('year')
if (currentName === selectedSchoolYear.name && next.toString() === current.toString()) {
return current
}
next.set('school_year', selectedSchoolYear.name)
return next
}, { replace: true })
}, [selectedSchoolYear, setSearchParams, token])
const value = useMemo<SchoolYearContextValue>(() => ({
schoolYears,
selectedSchoolYear,
selectedSchoolYearName: selectedSchoolYear?.name ?? null,
activeSchoolYear,
isLoadingSchoolYears,
schoolYearError,
isReadOnlyYear: isReadOnlySchoolYear(selectedSchoolYear),
setSelectedSchoolYearName: setRequestedYearName,
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
}
+27
View File
@@ -0,0 +1,27 @@
import { useMemo } from 'react'
import { useSchoolYear } from '../context/SchoolYearContext'
export function useSelectedSchoolYearParams() {
const { selectedSchoolYear, selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
return useMemo(() => {
const schoolYear = selectedSchoolYearName
const query = new URLSearchParams()
const headers: Record<string, string> = {}
if (schoolYear) {
query.set('school_year', schoolYear)
headers['X-School-Year'] = schoolYear
}
const queryString = query.toString()
return {
schoolYear,
query,
queryString,
headers,
isReadOnly: selectedSchoolYear ? isReadOnlyYear : false,
}
}, [isReadOnlyYear, selectedSchoolYear, selectedSchoolYearName])
}
+7 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthProvider'
import { CiPublicFooter } from '../components/CiPartials'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
@@ -169,7 +170,6 @@ export function MainLayout() {
<div className="container-fluid">
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
<img className="main-layout-logo" src="/logo.png" alt="Al Rahma Sunday School logo" />
Al Rahma Sunday School
</Link>
<button
className="navbar-toggler"
@@ -214,6 +214,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 +338,7 @@ export function MainLayout() {
</header>
<main className="container mt-4 flex-grow-1">
<ReadOnlyBanner />
<Outlet />
</main>
+8
View File
@@ -4,6 +4,7 @@ import { ApiHttpError } from '../api/http'
import { fetchNavMenu } from '../api/session'
import type { NavItem } from '../api/types'
import { useAuth } from '../auth/AuthProvider'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import {
isExternalNavUrl,
spaPathFromCiUrl,
@@ -230,6 +231,11 @@ export function ManagementLayout() {
const navTree = useMemo(() => normalizeNavItems(items), [items])
// Auto-hide sidebar after navigating to a subject
useEffect(() => {
setSidebarOpen(false)
}, [location.pathname])
return (
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
@@ -238,6 +244,7 @@ export function ManagementLayout() {
Al Rahma Sunday School
</Link>
<div className="d-flex align-items-center gap-2">
<SchoolYearSelector compact />
<span className="navbar-text small mb-0">{user?.name}</span>
<button
type="button"
@@ -309,6 +316,7 @@ export function ManagementLayout() {
</aside>
<main className="content management-main">
<ReadOnlyBanner />
<Outlet />
</main>
</div>
+4 -4
View File
@@ -4,14 +4,14 @@ const PROD_API_ORIGIN = 'https://api.alrahmaisgl.org'
* Laravel API base URL.
*
* Dev defaults to same-origin `/api/...` so Vite can proxy requests to Laravel.
* Set `VITE_API_ORIGIN` when you want the browser to call Laravel directly, e.g.
* `VITE_API_ORIGIN=http://localhost:8000 npm run dev`.
* Set `VITE_API_URL` when you want the browser to call Laravel directly, e.g.
* `VITE_API_URL=http://localhost:8000 npm run dev`.
*
* If `VITE_API_ORIGIN` is not set in dev, `vite.config.ts` proxies `/api` to
* If `VITE_API_URL` is not set in dev, `vite.config.ts` proxies `/api` to
* `VITE_PROXY_API` or `http://localhost:8000` by default.
*/
export function apiOrigin(): string {
const v = import.meta.env.VITE_API_ORIGIN
const v = import.meta.env.VITE_API_URL
if (typeof v === 'string' && v.trim()) {
return v.replace(/\/$/, '')
}
+1 -1
View File
@@ -66,7 +66,7 @@ function makeOption(
}
function optionFromRecord(record: SchoolYearRecord): SchoolYearOption | null {
return makeOption(record.name, record.status, Boolean(record.is_current), record.id)
return makeOption(record.name, record.status, Boolean(record.is_current || record.isCurrent), record.id)
}
function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null {
+101
View File
@@ -0,0 +1,101 @@
import type { SchoolYearRecord } from '../api/schoolYears'
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 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_NAME_STORAGE_KEY)
storage.removeItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)
return
}
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 name = getStoredSelectedSchoolYearName()
const status = getStoredSelectedSchoolYearStatus()
const headers: Record<string, string> = {}
if (name) {
headers['X-School-Year'] = name
}
if (status) {
headers['X-School-Year-Status'] = status
}
return headers
}
export function buildSchoolYearQuery(params: {
schoolYearName?: string | null
existing?: string | URLSearchParams
}): string {
const query = new URLSearchParams(params.existing)
const name = params.schoolYearName?.trim()
if (name) {
query.set('school_year', name)
}
const value = query.toString()
return value ? `?${value}` : ''
}
export function withSelectedSchoolYearUrl(url: string): string {
const name = getStoredSelectedSchoolYearName()
if (!name) return url
const [base, hash = ''] = url.split('#', 2)
const [path, rawQuery = ''] = base.split('?', 2)
const query = new URLSearchParams(rawQuery)
query.delete('school_year_id')
query.delete('year')
query.set('school_year', name)
const next = `${path}?${query.toString()}`
return hash ? `${next}#${hash}` : next
}
+27 -19
View File
@@ -37,6 +37,27 @@ type FlatContact = {
phone: string
}
type SortKey = keyof Omit<FlatContact, 'contactId' | 'parentId'>
function SortTh({ col, label, sortKey, sortDir, onSort }: {
col: SortKey
label: string
sortKey: SortKey
sortDir: 'asc' | 'desc'
onSort: (col: SortKey) => void
}) {
const active = sortKey === col
return (
<th
role="button"
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
onClick={() => onSort(col)}
>
{label} <span className="text-muted small">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th>
)
}
export function AdminEmergencyContactIndexPage() {
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
const [error, setError] = useState<string | null>(null)
@@ -82,7 +103,7 @@ export function AdminEmergencyContactIndexPage() {
const displayed = useMemo(() => {
const q = search.trim().toLowerCase()
let rows = q
const rows = q
? flatRows.filter((r) =>
r.parentName.toLowerCase().includes(q) ||
r.students.toLowerCase().includes(q) ||
@@ -97,19 +118,6 @@ export function AdminEmergencyContactIndexPage() {
})
}, [flatRows, search, sortKey, sortDir])
function SortTh({ col, label }: { col: typeof sortKey; label: string }) {
const active = sortKey === col
return (
<th
role="button"
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
onClick={() => toggleSort(col)}
>
{label} <span className="text-muted small">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th>
)
}
return (
<PageShell title="Emergency Contact Information">
{error ? <div className="alert alert-danger">{error}</div> : null}
@@ -132,11 +140,11 @@ export function AdminEmergencyContactIndexPage() {
<table className="table table-bordered table-striped align-middle">
<thead className="table-dark">
<tr>
<SortTh col="parentName" label="Parent Name" />
<SortTh col="students" label="Students" />
<SortTh col="contactName" label="Contact Name" />
<SortTh col="relation" label="Relation" />
<SortTh col="phone" label="Phone" />
<SortTh col="parentName" label="Parent Name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortTh col="students" label="Students" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortTh col="contactName" label="Contact Name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortTh col="relation" label="Relation" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortTh col="phone" label="Phone" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<th>Actions</th>
</tr>
</thead>
+220 -55
View File
@@ -2,7 +2,10 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 're
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import './AdminDailyAttendance.css'
import { ApiHttpError } from '../api/http'
import { fetchParentProfile, fetchParentProfiles, updateParentProfile, type ParentProfileDetail, type ParentProfileSummary } from '../api/parentProfiles'
import { CiAcademicFilter } from '../components/CiPartials'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import { useSchoolYear } from '../context/SchoolYearContext'
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
import {
assignTeacherClass,
@@ -25,7 +28,6 @@ import {
fetchClassAssignmentOverview,
fetchClassSections,
fetchExamDraftAdminData,
fetchFamilyAdminIndex,
fetchGradingOverview,
fetchIpBans,
fetchLateSlipLogs,
@@ -980,17 +982,17 @@ export function AdminCalendarAddEventPage() {
}, [])
if (loading && !options) {
return <AdminParityShell title="Add Event"><p className="text-muted">Loading</p></AdminParityShell>
return <AdminParityShell title="Add School Calendar Entry"><p className="text-muted">Loading</p></AdminParityShell>
}
return (
<CalendarEventForm
title="Add Event"
title="Add School Calendar Entry"
initial={{
event_types: options?.event_types ?? [],
defaults: options?.defaults,
}}
submitLabel="Add Event"
submitLabel="Add School Calendar Entry"
submitting={false}
onSubmit={async (payload) => {
await createAdministratorCalendarEvent(payload as never)
@@ -1029,18 +1031,18 @@ export function AdminCalendarEditPage() {
}, [eventId])
if (loading || !event) {
return <AdminParityShell title="Edit Event"><p className="text-muted">Loading</p></AdminParityShell>
return <AdminParityShell title="Edit School Calendar Entry"><p className="text-muted">Loading</p></AdminParityShell>
}
return (
<CalendarEventForm
title="Edit Event"
title="Edit School Calendar Entry"
initial={{
...event,
event_types: options?.event_types ?? [],
defaults: options?.defaults,
}}
submitLabel="Update Event"
submitLabel="Update School Calendar Entry"
submitting={false}
onSubmit={async (payload) => {
await updateAdministratorCalendarEvent(Number(eventId), payload)
@@ -1059,6 +1061,7 @@ export function AdminCalendarViewPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const { options, currentYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
@@ -1074,12 +1077,12 @@ export function AdminCalendarViewPage() {
try {
const data = await fetchAdministratorCalendarEvents({
schoolYear: schoolYear || null,
semester: null,
semester: semester || null,
})
setEvents(data.data?.events ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load calendar events.')
setError(e instanceof Error ? e.message : 'Unable to load school calendar entries.')
} finally {
setLoading(false)
}
@@ -1087,16 +1090,16 @@ export function AdminCalendarViewPage() {
useEffect(() => {
void load()
}, [schoolYear])
}, [schoolYear, semester])
async function onDelete(eventId: number) {
if (!window.confirm('Delete this event?')) return
if (!window.confirm('Delete this school calendar entry?')) return
await deleteAdministratorCalendarEvent(eventId)
await load()
}
return (
<AdminParityShell title="Event list">
<AdminParityShell title="School Calendar">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex gap-2 mb-3 flex-wrap justify-content-between align-items-center">
@@ -1107,7 +1110,7 @@ export function AdminCalendarViewPage() {
to={`/app/administrator/calendar_add_event${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}
className="btn btn-success"
>
Add New Event
Add School Calendar Entry
</Link>
</div>
@@ -1116,8 +1119,10 @@ export function AdminCalendarViewPage() {
onSubmit={(event) => {
event.preventDefault()
const year = String(new FormData(event.currentTarget).get('school_year') ?? '').trim()
const semester = String(new FormData(event.currentTarget).get('semester') ?? '').trim()
const next = new URLSearchParams()
if (year) next.set('school_year', year)
if (semester) next.set('semester', semester)
setSearchParams(next)
}}
>
@@ -1136,6 +1141,19 @@ export function AdminCalendarViewPage() {
))}
</select>
</div>
<div>
<label htmlFor="cal-semester" className="form-label">Semester</label>
<select
id="cal-semester"
name="semester"
className="form-select"
defaultValue={semester}
>
<option value="">-</option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="d-flex gap-2 align-self-end">
<button className="btn btn-secondary" type="submit">Apply</button>
<button
@@ -1159,15 +1177,18 @@ export function AdminCalendarViewPage() {
<th>Type</th>
<th>Description</th>
<th>Date</th>
<th>Audience</th>
<th>Notify Parent</th>
<th>Notify Teacher</th>
<th>Notify Admin</th>
<th>No School</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-muted">Loading events</td></tr>
<tr><td colSpan={10} className="text-muted">Loading school calendar entries</td></tr>
) : events.length === 0 ? (
<tr><td colSpan={7} className="text-muted">No events found.</td></tr>
<tr><td colSpan={10} className="text-muted">No school calendar entries found.</td></tr>
) : (
events.map((row) => (
<tr key={row.id ?? row.title + row.start}>
@@ -1176,7 +1197,10 @@ export function AdminCalendarViewPage() {
<td>{String(row.extendedProps?.event_type ?? '—')}</td>
<td>{row.description || '—'}</td>
<td>{formatDate(row.start)}</td>
<td>{eventAudienceBadges(row).join(', ') || '—'}</td>
<td className="text-center"><input type="checkbox" disabled checked={Boolean(row.extendedProps?.notify_parent)} readOnly /></td>
<td className="text-center"><input type="checkbox" disabled checked={Boolean(row.extendedProps?.notify_teacher)} readOnly /></td>
<td className="text-center"><input type="checkbox" disabled checked={Boolean(row.extendedProps?.notify_admin)} readOnly /></td>
<td className="text-center"><input type="checkbox" disabled checked={Boolean(row.extendedProps?.no_school)} readOnly /></td>
<td className="d-flex gap-2 flex-wrap">
{row.id ? (
<>
@@ -1188,7 +1212,7 @@ export function AdminCalendarViewPage() {
</button>
</>
) : (
<span className="text-muted small">Meeting event</span>
<span className="text-muted small">Meeting entry</span>
)}
</td>
</tr>
@@ -1350,7 +1374,7 @@ export function AdminClassAssignmentPage() {
function toggleSection(key: string) {
setExpandedSections((prev) => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
if (next.has(key)) next.delete(key); else next.add(key)
return next
})
}
@@ -2543,7 +2567,7 @@ export function AdminExamDraftsPage() {
const allowedExtensions = data?.allowed_extensions ?? ['doc', 'docx', 'pdf']
const maxUploadMb = Math.max(1, Math.round((data?.max_upload_bytes ?? 12 * 1024 * 1024) / 1024 / 1024))
const classSections = data?.class_sections ?? []
const drafts = (data?.drafts ?? []).filter((draft) => !Boolean(draft.is_legacy))
const drafts = (data?.drafts ?? []).filter((draft) => !draft.is_legacy)
const legacyByClass = data?.legacy_by_class ?? {}
const draftsByClass = useMemo(() => {
@@ -3465,19 +3489,28 @@ export function AdminPrintNotificationAdminsPage() {
}
export function AdminParentProfilePage() {
const [guardians, setGuardians] = useState<NotificationAdminRow[]>([])
const [selectedGuardianId, setSelectedGuardianId] = useState<number | null>(null)
const [families, setFamilies] = useState<Array<Record<string, unknown>>>([])
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const [searchParams, setSearchParams] = useSearchParams()
const [query, setQuery] = useState(searchParams.get('q') ?? '')
const [parents, setParents] = useState<ParentProfileSummary[]>([])
const [selectedParentId, setSelectedParentId] = useState<number | null>(Number(searchParams.get('parent_id')) || null)
const [detail, setDetail] = useState<ParentProfileDetail | null>(null)
const [pagination, setPagination] = useState({ page: Number(searchParams.get('page')) || 1, per_page: 25, total: 0 })
const [loading, setLoading] = useState(true)
const [detailLoading, setDetailLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!selectedSchoolYearName) return
;(async () => {
setLoading(true)
try {
const data = await fetchFamilyAdminIndex()
setGuardians(data.data?.searchGuardians ?? [])
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
const page = Number(searchParams.get('page')) || 1
const q = searchParams.get('q') ?? ''
const data = await fetchParentProfiles({ schoolYear: selectedSchoolYearName, q, page, perPage: 25 })
setParents(data.parents ?? [])
setPagination(data.pagination ?? { page, per_page: 25, total: 0 })
setQuery(q)
setError(null)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profiles.')
@@ -3485,31 +3518,98 @@ export function AdminParentProfilePage() {
setLoading(false)
}
})()
}, [])
}, [searchParams, selectedSchoolYearName])
async function loadGuardian(guardianId: number) {
setSelectedGuardianId(guardianId)
useEffect(() => {
if (!selectedSchoolYearName || !selectedParentId) {
setDetail(null)
return
}
;(async () => {
setDetailLoading(true)
try {
setDetail(await fetchParentProfile(selectedParentId, selectedSchoolYearName))
setError(null)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profile.')
} finally {
setDetailLoading(false)
}
})()
}, [selectedParentId, selectedSchoolYearName])
function applySearch(event: FormEvent) {
event.preventDefault()
if (!selectedSchoolYearName) return
const next = new URLSearchParams(searchParams)
next.set('school_year', selectedSchoolYearName)
next.set('page', '1')
if (query.trim()) next.set('q', query.trim())
else next.delete('q')
setSearchParams(next)
}
function selectParent(parentId: number) {
if (!selectedSchoolYearName) return
setSelectedParentId(parentId)
const next = new URLSearchParams(searchParams)
next.set('school_year', selectedSchoolYearName)
next.set('parent_id', String(parentId))
setSearchParams(next)
}
function money(value: unknown): string {
return Number(value ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD' })
}
async function saveParentProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!detail?.parent || !selectedParentId || isReadOnlyYear || !selectedSchoolYearName) return
setError(null)
try {
const data = await fetchFamilyAdminIndex({ guardianId })
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
const form = new FormData(event.currentTarget)
const payload = Object.fromEntries(form.entries())
const updated = await updateParentProfile(selectedParentId, selectedSchoolYearName, payload)
setDetail(updated)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load families.')
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to update parent profile.')
}
}
const parent = detail?.parent ?? null
const fullName = parent ? `${String(parent.firstname ?? '')} ${String(parent.lastname ?? '')}`.trim() : ''
return (
<AdminParityShell title="Parent Profiles">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div className="d-flex align-items-center gap-2">
<SchoolYearSelector compact />
{selectedSchoolYearName ? <span className="badge bg-secondary">{selectedSchoolYearName}</span> : null}
</div>
<form className="d-flex gap-2" onSubmit={applySearch}>
<input className="form-control" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search parents, email, phone, student" />
<button type="submit" className="btn btn-primary">Search</button>
</form>
</div>
<ReadOnlyBanner className="mb-3" />
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3">
<div className="col-lg-4">
<div className="card">
<div className="card-header">Guardians</div>
<div className="list-group list-group-flush" style={{ maxHeight: 520, overflow: 'auto' }}>
{loading ? <div className="list-group-item text-muted">Loading guardians</div> : guardians.map((guardian) => (
<button key={guardian.id} type="button" className={`list-group-item list-group-item-action ${selectedGuardianId === guardian.id ? 'active' : ''}`} onClick={() => void loadGuardian(guardian.id)}>
<div>{`${guardian.firstname ?? ''} ${guardian.lastname ?? ''}`.trim()}</div>
<div className="small">{guardian.email ?? ''}</div>
<div className="card-header d-flex justify-content-between">
<span>Parents</span>
<span className="text-muted small">{pagination.total} found</span>
</div>
<div className="list-group list-group-flush" style={{ maxHeight: 640, overflow: 'auto' }}>
{loading ? <div className="list-group-item text-muted">Loading parents...</div> : null}
{!loading && parents.length === 0 ? <div className="list-group-item text-muted">No selected-year parents found.</div> : null}
{parents.map((parentRow) => (
<button key={parentRow.id} type="button" className={`list-group-item list-group-item-action ${selectedParentId === parentRow.id ? 'active' : ''}`} onClick={() => selectParent(parentRow.id)}>
<div className="fw-semibold">{`${parentRow.firstname ?? ''} ${parentRow.lastname ?? ''}`.trim() || `Parent #${parentRow.id}`}</div>
<div className="small">{parentRow.email ?? ''}</div>
<div className="small text-muted">
Students: {parentRow.selected_year_students_count ?? 0} | Balance: {money(parentRow.balance)}
</div>
</button>
))}
</div>
@@ -3517,21 +3617,87 @@ export function AdminParentProfilePage() {
</div>
<div className="col-lg-8">
<div className="card">
<div className="card-header">Families</div>
<div className="card-header d-flex justify-content-between">
<span>{fullName || 'Parent Detail'}</span>
{detailLoading ? <span className="text-muted small">Loading...</span> : null}
</div>
<div className="card-body">
{families.length === 0 ? <p className="text-muted mb-0">Select a guardian to view family details.</p> : families.map((family) => (
<div key={String(family.id ?? '')} className="border rounded p-3 mb-3">
<h5 className="mb-1">{String(family.household_name ?? 'Household')}</h5>
<div className="small text-muted mb-2">{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}</div>
<div className="mb-2"><strong>Phone:</strong> {String(family.primary_phone ?? '—')}</div>
<div><strong>Students</strong></div>
<ul className="mb-0">
{Array.isArray(family.students) && family.students.length > 0 ? family.students.map((student, idx) => (
<li key={idx}>{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()} {student.class_section_name ? `(${String(student.class_section_name)})` : ''}</li>
)) : <li className="text-muted">No students linked.</li>}
</ul>
{!detail ? <p className="text-muted mb-0">Select a parent to view selected-year details.</p> : null}
{detail && parent ? (
<div className="d-grid gap-3">
<form className="row g-2" onSubmit={saveParentProfile}>
{['firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip'].map((field) => (
<div className={field === 'address_street' ? 'col-12' : 'col-md-6'} key={field}>
<label className="form-label text-capitalize">{field.replace('_', ' ')}</label>
<input className="form-control" name={field} defaultValue={String(parent[field] ?? '')} disabled={isReadOnlyYear} />
</div>
))}
<div className="col-12">
<button type="submit" className="btn btn-primary" disabled={isReadOnlyYear}>Save Profile</button>
</div>
</form>
<div className="row g-2">
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Balance</div><strong>{money(detail.finance_summary.balance)}</strong></div></div>
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Unpaid</div><strong>{money(detail.finance_summary.positive_unpaid_balance)}</strong></div></div>
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Credit</div><strong>{money(detail.finance_summary.credit_balance)}</strong></div></div>
</div>
<section>
<h5>Students</h5>
{detail.students.length === 0 ? <p className="text-muted">No students for this school year.</p> : (
<table className="table table-sm align-middle">
<tbody>
{detail.students.map((student) => (
<tr key={String(student.id)}>
<td>{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()}</td>
<td>{String(student.class_section_name ?? student.grade ?? '')}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section>
<h5>Families</h5>
{detail.families.length === 0 ? <p className="text-muted">No selected-year families.</p> : detail.families.map((family) => (
<div key={String(family.id)} className="border rounded p-2 mb-2">
<strong>{String(family.household_name ?? 'Household')}</strong>
<div className="small text-muted">{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}</div>
</div>
))}
</section>
<section>
<h5>Emergency Contacts</h5>
{detail.emergency_contacts.length === 0 ? <p className="text-muted">No selected-year emergency contacts.</p> : detail.emergency_contacts.map((contact) => (
<div key={String(contact.id)} className="border rounded p-2 mb-2">
<strong>{String(contact.emergency_contact_name ?? '')}</strong>
<div className="small text-muted">{String(contact.relation ?? '')} {String(contact.cellphone ?? '')} {String(contact.email ?? '')}</div>
</div>
))}
</section>
<section>
<h5>Invoices</h5>
{detail.invoices.length === 0 ? <p className="text-muted">No selected-year invoices.</p> : (
<table className="table table-sm align-middle">
<thead><tr><th>Invoice</th><th>Status</th><th className="text-end">Balance</th></tr></thead>
<tbody>
{detail.invoices.map((invoice) => (
<tr key={String(invoice.id)}>
<td>{String(invoice.invoice_number ?? invoice.id ?? '')}</td>
<td>{String(invoice.status ?? '')}</td>
<td className="text-end">{money(invoice.balance)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
) : null}
</div>
</div>
</div>
@@ -3933,7 +4099,6 @@ export function AdminSectionsAutoDistributePage() {
async function runAll() {
for (const row of rows) {
// eslint-disable-next-line no-await-in-loop
await runDistribution(row)
}
}
@@ -4318,8 +4483,8 @@ export function AdminStudentProfilesPage() {
student.registration_grade,
student.dob,
student.registration_date,
Boolean(student.is_active) ? 'yes active' : 'no inactive',
Boolean(student.photo_consent) ? 'yes consent' : 'no consent',
student.is_active ? 'yes active' : 'no inactive',
student.photo_consent ? 'yes consent' : 'no consent',
]
.map((value) => String(value ?? '').toLowerCase())
.join(' ')
@@ -4391,8 +4556,8 @@ export function AdminStudentProfilesPage() {
<td>{student.age ?? '—'}</td>
<td>{student.gender ?? '—'}</td>
<td>{student.registration_grade ?? '—'}</td>
<td>{Boolean(student.is_active) ? 'Yes' : 'No'}</td>
<td>{Boolean(student.photo_consent) ? 'Yes' : 'No'}</td>
<td>{student.is_active ? 'Yes' : 'No'}</td>
<td>{student.photo_consent ? 'Yes' : 'No'}</td>
<td>{formatDate(student.registration_date)}</td>
<td><button className="btn btn-sm btn-primary" type="button" onClick={() => void openDetails(student)}>View / Edit</button></td>
</tr>
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } 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 [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year')?.trim() ?? ''
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)
useEffect(() => {
if (!schoolYear) {
setError('Missing school_year query parameter.')
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
;(async () => {
try {
const [nextSummary, nextPromotion, nextBalances] = await Promise.all([
fetchSchoolYearSummary(schoolYear),
fetchSchoolYearPromotionPreview(schoolYear),
fetchSchoolYearParentBalances(schoolYear),
])
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
}
}, [schoolYear])
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">Unpaid carryover parents</div>
<div className="display-6">{totals?.parents_with_positive_balance ?? 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_impact ?? 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_positive_balance ?? 0} parents with unpaid balances,
{balances?.parents_with_credit_balance ?? 0} parents with credits,
net ${formatMoney(balances?.net_balance_impact ?? 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">Unpaid</th><th className="text-end">Credits</th><th className="text-end">Net</th></tr></thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={5} 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.positive_unpaid_balance ?? row.amount_to_transfer)}</td>
<td className="text-end">${formatMoney(row.credit_balance)}</td>
<td className="text-end">${formatMoney(row.net_balance)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</>
) : null}
</div>
)
}
@@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } 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">Credits carried or reported</div><div className="h4 mb-0">${formatMoney(totals.total_credit_carried_or_reported ?? totals.total_credit_transferred)}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Net balance impact</div><div className="h4 mb-0">${formatMoney(totals.net_balance_impact)}</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 [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year')?.trim() ?? ''
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 (!schoolYear) {
setError('Missing school_year query parameter.')
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
setClosingReport(null)
setBalanceReport(null)
;(async () => {
try {
if (reportType === 'closing') {
const report = await fetchSchoolYearClosingReport(schoolYear)
if (!cancelled) setClosingReport(report)
} else {
const report = await fetchSchoolYearBalanceTransferReport(schoolYear)
if (!cancelled) setBalanceReport(report)
}
} catch (loadError) {
if (!cancelled) setError(normalizeApiError(loadError, `Unable to load ${title.toLowerCase()}.`))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [reportType, schoolYear, 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/summary?school_year=${encodeURIComponent(schoolYear)}`}>
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) {
@@ -131,6 +121,86 @@ function closePayloadFrom(state: CloseFormState): CloseSchoolYearPayload {
}
}
const PAGE_SIZE_OPTIONS = [10, 20, 100] as const
type PageSize = (typeof PAGE_SIZE_OPTIONS)[number]
function paginateRows<T>(rows: T[], page: number, pageSize: PageSize) {
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
const currentPage = Math.min(Math.max(page, 1), totalPages)
const start = (currentPage - 1) * pageSize
return {
currentPage,
totalPages,
rows: rows.slice(start, start + pageSize),
start: rows.length === 0 ? 0 : start + 1,
end: Math.min(start + pageSize, rows.length),
}
}
function PaginationControls({
label,
totalRows,
page,
pageSize,
totalPages,
start,
end,
onPageChange,
onPageSizeChange,
}: {
label: string
totalRows: number
page: number
pageSize: PageSize
totalPages: number
start: number
end: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: PageSize) => void
}) {
if (totalRows === 0) return null
return (
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 small text-muted mb-2">
<div>
Showing {start}-{end} of {totalRows} {label}
</div>
<div className="d-flex align-items-center gap-2">
<label className="d-flex align-items-center gap-1 mb-0">
<span>Rows</span>
<select
className="form-select form-select-sm"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value) as PageSize)}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</label>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
</button>
</div>
</div>
)
}
export function SchoolYearsManagementPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [years, setYears] = useState<SchoolYearRecord[]>([])
@@ -143,6 +213,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())
@@ -150,7 +221,15 @@ export function SchoolYearsManagementPage() {
const [busyEdit, setBusyEdit] = useState(false)
const [busyOpenId, setBusyOpenId] = useState<number | null>(null)
const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | null>(null)
const selectedYearId = Number(searchParams.get('year') ?? 0) || null
const [promotionPage, setPromotionPage] = useState(1)
const [promotionPageSize, setPromotionPageSize] = useState<PageSize>(10)
const [balancePage, setBalancePage] = useState(1)
const [balancePageSize, setBalancePageSize] = useState<PageSize>(10)
const [closePromotionPage, setClosePromotionPage] = useState(1)
const [closePromotionPageSize, setClosePromotionPageSize] = useState<PageSize>(10)
const [closeBalancePage, setCloseBalancePage] = useState(1)
const [closeBalancePageSize, setCloseBalancePageSize] = useState<PageSize>(10)
const selectedYearName = searchParams.get('school_year')?.trim() || null
const activeYear = useMemo(
() => years.find((year) => Boolean(year.is_current)) ?? null,
@@ -159,13 +238,13 @@ export function SchoolYearsManagementPage() {
const selectedYear = useMemo(() => {
if (years.length === 0) return null
if (selectedYearId != null) {
return years.find((year) => year.id === selectedYearId) ?? null
if (selectedYearName) {
return years.find((year) => year.name === selectedYearName) ?? null
}
return activeYear ?? years[0] ?? null
}, [activeYear, selectedYearId, years])
}, [activeYear, selectedYearName, years])
async function refreshYears(preferredYearId?: number | null) {
async function refreshYears(preferredYearName?: string | null) {
setLoading(true)
setLoadingMessage('Loading school years…')
setError(null)
@@ -175,15 +254,17 @@ export function SchoolYearsManagementPage() {
setYears(list)
const nextSelectedId =
preferredYearId ??
(selectedYearId != null && list.some((year) => year.id === selectedYearId)
? selectedYearId
: list.find((year) => year.is_current)?.id ?? list[0]?.id ?? null)
preferredYearName ??
(selectedYearName && list.some((year) => year.name === selectedYearName)
? selectedYearName
: list.find((year) => year.is_current)?.name ?? list[0]?.name ?? null)
if (nextSelectedId != null) {
setSearchParams((current) => {
const next = new URLSearchParams(current)
next.set('year', String(nextSelectedId))
next.delete('year')
next.delete('school_year_id')
next.set('school_year', nextSelectedId)
return next
}, { replace: true })
}
@@ -213,6 +294,7 @@ export function SchoolYearsManagementPage() {
setParentBalances(null)
setCloseValidation(null)
setClosePreview(null)
setCloseResult(null)
setError(normalizeApiError(loadError, 'Unable to load school years.'))
} finally {
setLoading(false)
@@ -246,7 +328,7 @@ export function SchoolYearsManagementPage() {
;(async () => {
try {
const nextSummary = await fetchSchoolYearSummary(selectedYear.id)
const nextSummary = await fetchSchoolYearSummary(selectedYear.name)
if (!cancelled) {
setSummary(nextSummary)
}
@@ -281,14 +363,17 @@ export function SchoolYearsManagementPage() {
setMessages([{ type, message }])
}
function selectYear(yearId: number) {
function selectYear(yearName: string) {
setSearchParams((current) => {
const next = new URLSearchParams(current)
next.set('year', String(yearId))
next.delete('year')
next.delete('school_year_id')
next.set('school_year', yearName)
return next
})
setPromotionPreview(null)
setParentBalances(null)
setCloseResult(null)
}
async function handleCreate(event: FormEvent) {
@@ -299,7 +384,7 @@ export function SchoolYearsManagementPage() {
const created = await createSchoolYear(createForm)
pushMessage('success', `Draft school year ${created.name} created.`)
setCreateForm(emptyYearForm())
await refreshYears(created.id)
await refreshYears(created.name)
} catch (saveError) {
setError(normalizeApiError(saveError, 'Unable to create school year.'))
} finally {
@@ -313,9 +398,9 @@ export function SchoolYearsManagementPage() {
setBusyEdit(true)
setError(null)
try {
const updated = await updateSchoolYear(selectedYear.id, editForm)
const updated = await updateSchoolYear(selectedYear.name, editForm)
pushMessage('success', `School year ${updated.name} updated.`)
await refreshYears(updated.id)
await refreshYears(updated.name)
} catch (saveError) {
setError(normalizeApiError(saveError, 'Unable to update school year.'))
} finally {
@@ -327,11 +412,11 @@ export function SchoolYearsManagementPage() {
setBusyOpenId(year.id)
setError(null)
try {
const reopened = await reopenSchoolYear(year.id)
const reopened = await reopenSchoolYear(year.name)
pushMessage('success', `School year ${reopened.name} is now active.`)
setCloseValidation(null)
setClosePreview(null)
await refreshYears(reopened.id)
await refreshYears(reopened.name)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to open school year.'))
} finally {
@@ -345,10 +430,11 @@ export function SchoolYearsManagementPage() {
setError(null)
try {
const preview = await fetchSchoolYearPromotionPreview(
selectedYear.id,
selectedYear.name,
suggestNextName(selectedYear.name),
)
setPromotionPreview(preview)
setPromotionPage(1)
} catch (loadError) {
setError(normalizeApiError(loadError, 'Unable to load promotion preview.'))
}
@@ -360,23 +446,25 @@ export function SchoolYearsManagementPage() {
setError(null)
try {
const balances = await fetchSchoolYearParentBalances(
selectedYear.id,
selectedYear.name,
suggestNextName(selectedYear.name),
)
setParentBalances(balances)
setBalancePage(1)
} catch (loadError) {
setError(normalizeApiError(loadError, 'Unable to load parent balance preview.'))
}
}
async function handleValidateClose() {
if (!activeYear) return
if (!selectedYear?.is_current) return
setBusyCloseAction('validate')
setError(null)
try {
const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
const validation = await validateSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm))
setCloseValidation(validation)
setClosePreview(null)
setCloseResult(null)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to validate school year closure.'))
} finally {
@@ -385,13 +473,14 @@ export function SchoolYearsManagementPage() {
}
async function handlePreviewClose() {
if (!activeYear) return
if (!selectedYear?.is_current) return
setBusyCloseAction('preview')
setError(null)
try {
const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
const preview = await previewSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm))
setCloseValidation(preview.validation)
setClosePreview(preview)
setCloseResult(null)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to preview school year closure.'))
} finally {
@@ -401,21 +490,22 @@ export function SchoolYearsManagementPage() {
async function handleClose(event: FormEvent) {
event.preventDefault()
if (!activeYear) return
if (!selectedYear?.is_current) return
setBusyCloseAction('close')
setError(null)
try {
const result = await closeSchoolYear(activeYear.id, closePayloadFrom(closeForm))
const result = await closeSchoolYear(selectedYear.name, closePayloadFrom(closeForm))
pushMessage(
'success',
`Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`,
)
setCloseValidation(null)
setClosePreview(null)
setCloseResult(result)
setPromotionPreview(null)
setParentBalances(null)
setCloseForm(emptyCloseForm())
await refreshYears(result.new_school_year.id)
await refreshYears(result.new_school_year.name)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to close school year.'))
} finally {
@@ -428,6 +518,14 @@ export function SchoolYearsManagementPage() {
const balanceRows = parentBalances?.rows ?? []
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
const closeBalanceRows = closePreview?.parent_balances.rows ?? []
const pagedPromotionRows = paginateRows(promotionRows, promotionPage, promotionPageSize)
const pagedBalanceRows = paginateRows(balanceRows, balancePage, balancePageSize)
const pagedClosePromotionRows = paginateRows(closePromotionRows, closePromotionPage, closePromotionPageSize)
const pagedCloseBalanceRows = paginateRows(closeBalanceRows, closeBalancePage, closeBalancePageSize)
const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear)
const selectedYearIsActive = Boolean(selectedYear?.is_current)
const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : ''
const confirmationMatches = !selectedYearIsActive || closeForm.confirmation.trim() === requiredConfirmation
return (
<div className="container-fluid py-4">
@@ -453,10 +551,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}
@@ -482,7 +583,7 @@ export function SchoolYearsManagementPage() {
<button
type="button"
className="btn btn-link p-0 fw-semibold text-decoration-none"
onClick={() => selectYear(year.id)}
onClick={() => selectYear(year.name)}
>
{year.name}
</button>
@@ -490,18 +591,22 @@ 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
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => selectYear(year.id)}
onClick={() => selectYear(year.name)}
>
Inspect
</button>
<Link
className="btn btn-sm btn-outline-secondary"
to={`/app/admin/school-years/summary?school_year=${encodeURIComponent(year.name)}`}
>
Summary
</Link>
{!year.is_current ? (
<button
type="button"
@@ -604,9 +709,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>
@@ -630,14 +733,14 @@ export function SchoolYearsManagementPage() {
</div>
<div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Parents with balances</div>
<div className="display-6">{summaryCards.parents_with_balances}</div>
<div className="small text-muted text-uppercase">Unpaid carryover parents</div>
<div className="display-6">{summaryCards.parents_with_positive_balance ?? summaryCards.parents_with_balances}</div>
</div>
</div>
<div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Net balance impact</div>
<div className="display-6">${formatMoney(summaryCards.net_balance_to_transfer)}</div>
<div className="display-6">${formatMoney(summaryCards.net_balance_impact ?? summaryCards.net_balance_to_transfer)}</div>
</div>
</div>
</div>
@@ -645,9 +748,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/summary?school_year=${encodeURIComponent(selectedYear.name)}`}>
Open summary page
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/reports/closing?school_year=${encodeURIComponent(selectedYear.name)}`}>
Closing report
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/reports/balance-transfers?school_year=${encodeURIComponent(selectedYear.name)}`}>
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 +782,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 +799,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 +815,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 +827,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>
@@ -752,6 +877,20 @@ export function SchoolYearsManagementPage() {
{promotionPreview.summary.total_students} students scanned,{' '}
{promotionPreview.summary.hold} hold rows.
</div>
<PaginationControls
label="students"
totalRows={promotionRows.length}
page={pagedPromotionRows.currentPage}
pageSize={promotionPageSize}
totalPages={pagedPromotionRows.totalPages}
start={pagedPromotionRows.start}
end={pagedPromotionRows.end}
onPageChange={setPromotionPage}
onPageSizeChange={(pageSize) => {
setPromotionPageSize(pageSize)
setPromotionPage(1)
}}
/>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
@@ -770,7 +909,7 @@ export function SchoolYearsManagementPage() {
</td>
</tr>
) : (
promotionRows.slice(0, 8).map((row) => (
pagedPromotionRows.rows.map((row) => (
<tr key={row.student_id}>
<td>
<div>{row.student_name}</div>
@@ -802,31 +941,54 @@ export function SchoolYearsManagementPage() {
) : (
<>
<div className="small text-muted mb-3">
{parentBalances.summary.parents_with_non_zero_balance} parents with
balances, net ${formatMoney(parentBalances.summary.net_balance_to_transfer)}.
Source year: {parentBalances.from_school_year}; target year: {parentBalances.to_school_year}.{' '}
Unpaid ${formatMoney(parentBalances.summary.total_positive_unpaid_balance ?? parentBalances.summary.total_old_unpaid_balance)},{' '}
credits ${formatMoney(parentBalances.summary.total_credit_balance)}, net ${formatMoney(parentBalances.summary.net_balance_impact ?? parentBalances.summary.net_balance_to_transfer)}.
</div>
<PaginationControls
label="parents"
totalRows={balanceRows.length}
page={pagedBalanceRows.currentPage}
pageSize={balancePageSize}
totalPages={pagedBalanceRows.totalPages}
start={pagedBalanceRows.start}
end={pagedBalanceRows.end}
onPageChange={setBalancePage}
onPageSizeChange={(pageSize) => {
setBalancePageSize(pageSize)
setBalancePage(1)
}}
/>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Parent</th>
<th>Students</th>
<th>Transfer</th>
<th className="text-end">Unpaid</th>
<th className="text-end">Credits</th>
<th className="text-end">Net</th>
<th>Sources</th>
<th>Conflict</th>
</tr>
</thead>
<tbody>
{balanceRows.length === 0 ? (
<tr>
<td colSpan={3} className="text-muted">
<td colSpan={7} className="text-muted">
No rows found.
</td>
</tr>
) : (
balanceRows.slice(0, 8).map((row) => (
pagedBalanceRows.rows.map((row) => (
<tr key={row.parent_id}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td>
<td>${formatMoney(row.amount_to_transfer)}</td>
<td className="text-end">${formatMoney(row.positive_unpaid_balance ?? row.old_unpaid_balance)}</td>
<td className="text-end">${formatMoney(row.credit_balance)}</td>
<td className="text-end">${formatMoney(row.net_balance ?? row.amount_to_transfer)}</td>
<td>{(row.positive_invoice_ids ?? row.source_invoice_ids ?? []).join(', ') || '—'}</td>
<td>{row.existing_transfer_conflict ? <span className="badge text-bg-warning">Conflict</span> : '—'}</td>
</tr>
))
)}
@@ -845,20 +1007,39 @@ export function SchoolYearsManagementPage() {
<div className="card-body">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 className="h5 mb-1">Close Active Year</h2>
<h2 className="h5 mb-1">Close Selected Active Year</h2>
<div className="text-muted small">
Validate, preview, and close the current year through the transactional
school-year closure endpoints.
Validate, preview, and close the inspected school year through the transactional
school-year closure endpoints. Only the current active year can be closed.
</div>
</div>
{activeYear ? (
<span className="badge text-bg-success">{activeYear.name}</span>
{selectedYearIsActive && selectedYear ? (
<span className="badge text-bg-success">{selectedYear.name}</span>
) : null}
</div>
{!activeYear ? (
<p className="text-muted mb-0">No active school year is available to close.</p>
{!selectedYear ? (
<p className="text-muted mb-0">Select a school year to close it.</p>
) : !selectedYearIsActive ? (
<div className="alert alert-info mb-0">
Only the current active school year can be closed. You are inspecting {selectedYear.name}, which is {selectedYear.status}.
</div>
) : (
<>
<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 +1103,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">
@@ -951,7 +1135,7 @@ export function SchoolYearsManagementPage() {
<button
type="button"
className="btn btn-outline-primary"
disabled={busyCloseAction === 'validate'}
disabled={busyCloseAction === 'validate' || !selectedYearIsActive}
onClick={() => void handleValidateClose()}
>
{busyCloseAction === 'validate' ? 'Validating…' : 'Validate'}
@@ -959,16 +1143,21 @@ export function SchoolYearsManagementPage() {
<button
type="button"
className="btn btn-outline-secondary"
disabled={busyCloseAction === 'preview'}
disabled={busyCloseAction === 'preview' || !selectedYearIsActive}
onClick={() => void handlePreviewClose()}
>
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
</button>
<button type="submit" className="btn btn-danger" disabled={busyCloseAction === 'close'}>
{busyCloseAction === 'close' ? 'Closing…' : `Close ${activeYear.name}`}
<button
type="submit"
className="btn btn-danger"
disabled={busyCloseAction === 'close' || !confirmationMatches || !selectedYearIsActive}
>
{busyCloseAction === 'close' ? 'Closing…' : `Close ${selectedYear.name}`}
</button>
</div>
</form>
</>
)}
{closeValidation ? (
@@ -984,10 +1173,10 @@ export function SchoolYearsManagementPage() {
Missing decisions: <strong>{closeValidation.promotion_summary.hold}</strong>
</div>
<div className="col-md-3">
Parents with balances: <strong>{closeValidation.financial_summary.parents_with_non_zero_balance}</strong>
Unpaid carryover parents: <strong>{closeValidation.financial_summary.parents_with_positive_balance ?? closeValidation.financial_summary.parents_with_non_zero_balance}</strong>
</div>
<div className="col-md-3">
Net transfer: <strong>${formatMoney(closeValidation.financial_summary.net_balance_to_transfer)}</strong>
Net impact: <strong>${formatMoney(closeValidation.financial_summary.net_balance_impact ?? closeValidation.financial_summary.net_balance_to_transfer)}</strong>
</div>
</div>
{closeValidation.errors.length > 0 ? (
@@ -1007,12 +1196,38 @@ 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">
<div className="col-lg-6">
<div className="border rounded p-3 h-100">
<h3 className="h6">Closure promotion preview</h3>
<PaginationControls
label="students"
totalRows={closePromotionRows.length}
page={pagedClosePromotionRows.currentPage}
pageSize={closePromotionPageSize}
totalPages={pagedClosePromotionRows.totalPages}
start={pagedClosePromotionRows.start}
end={pagedClosePromotionRows.end}
onPageChange={setClosePromotionPage}
onPageSizeChange={(pageSize) => {
setClosePromotionPageSize(pageSize)
setClosePromotionPage(1)
}}
/>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
@@ -1023,7 +1238,7 @@ export function SchoolYearsManagementPage() {
</tr>
</thead>
<tbody>
{closePromotionRows.slice(0, 8).map((row) => (
{pagedClosePromotionRows.rows.map((row) => (
<tr key={`close-${row.student_id}`}>
<td>{row.student_name}</td>
<td>{row.promotion_action}</td>
@@ -1038,21 +1253,44 @@ export function SchoolYearsManagementPage() {
<div className="col-lg-6">
<div className="border rounded p-3 h-100">
<h3 className="h6">Closure balance preview</h3>
<div className="small text-muted mb-2">
Source year: {closePreview.parent_balances.from_school_year}; target year: {closePreview.parent_balances.to_school_year}. Transfer mode: unpaid balances only.
</div>
<PaginationControls
label="parents"
totalRows={closeBalanceRows.length}
page={pagedCloseBalanceRows.currentPage}
pageSize={closeBalancePageSize}
totalPages={pagedCloseBalanceRows.totalPages}
start={pagedCloseBalanceRows.start}
end={pagedCloseBalanceRows.end}
onPageChange={setCloseBalancePage}
onPageSizeChange={(pageSize) => {
setCloseBalancePageSize(pageSize)
setCloseBalancePage(1)
}}
/>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Parent</th>
<th>Students</th>
<th>Transfer</th>
<th className="text-end">Unpaid</th>
<th className="text-end">Credits</th>
<th className="text-end">Net</th>
<th>Conflict</th>
</tr>
</thead>
<tbody>
{closeBalanceRows.slice(0, 8).map((row) => (
{pagedCloseBalanceRows.rows.map((row) => (
<tr key={`balance-${row.parent_id}`}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td>
<td>${formatMoney(row.amount_to_transfer)}</td>
<td className="text-end">${formatMoney(row.positive_unpaid_balance ?? row.old_unpaid_balance)}</td>
<td className="text-end">${formatMoney(row.credit_balance)}</td>
<td className="text-end">${formatMoney(row.net_balance ?? row.amount_to_transfer)}</td>
<td>{row.existing_transfer_conflict ? <span className="badge text-bg-warning">Conflict</span> : '—'}</td>
</tr>
))}
</tbody>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import {
fetchClassProgressGroups,
fetchClassProgressMeta,
@@ -20,6 +20,9 @@ export function ClassProgressListPage() {
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const selectedSchoolYear = searchParams.get('school_year')?.trim() || null
const selectedSemester = searchParams.get('semester')?.trim() || null
/** Server may later expose `low_progress_section_ids`; until then keep empty (CI disables button). */
const lowProgressSectionIds: number[] = []
@@ -33,10 +36,12 @@ export function ClassProgressListPage() {
async function load() {
try {
const [meta, sections, groups] = await Promise.all([
fetchClassProgressMeta(),
fetchClassSections({ perPage: 200, withStudents: true }),
fetchClassProgressMeta({ schoolYear: selectedSchoolYear, semester: selectedSemester }),
fetchClassSections({ perPage: 200, withStudents: true, schoolYear: selectedSchoolYear, semester: selectedSemester }),
fetchClassProgressGroups({
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
schoolYear: selectedSchoolYear,
semester: selectedSemester,
status: filters.status || null,
weekStart: filters.weekStart || null,
weekEnd: filters.weekEnd || null,
@@ -55,7 +60,7 @@ export function ClassProgressListPage() {
useEffect(() => {
void load()
}, [])
}, [selectedSchoolYear, selectedSemester])
const groupsBySection = useMemo(() => {
const m = new Map<number, ClassProgressGroupRow[]>()
@@ -69,6 +74,26 @@ export function ClassProgressListPage() {
return m
}, [items])
const displaySections = useMemo(() => {
const byId = new Map<number, { class_section_id?: number | null; class_section_name?: string | null }>()
for (const section of classSections) {
const id = Number(section.class_section_id ?? 0)
if (id > 0) byId.set(id, section)
}
for (const item of items) {
const id = Number(item.class_section_id ?? 0)
if (id > 0 && !byId.has(id)) {
byId.set(id, {
class_section_id: id,
class_section_name: item.class_section_name ?? `Section ${id}`,
})
}
}
return Array.from(byId.values()).sort((a, b) =>
String(a.class_section_name ?? '').localeCompare(String(b.class_section_name ?? '')),
)
}, [classSections, items])
const subjectCount = Object.keys(subjectSections).length
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
@@ -218,11 +243,11 @@ export function ClassProgressListPage() {
<div className="card shadow-sm mt-5">
<div className="card-body pt-4">
{classSections.length === 0 ? (
{displaySections.length === 0 ? (
<div className="text-center text-muted py-4">No class sections found.</div>
) : (
<div className="accordion" id="adminProgressAccordion">
{classSections.map((cs) => {
{displaySections.map((cs) => {
const sectionId = Number(cs.class_section_id ?? 0)
const sectionName = cs.class_section_name ?? 'Unknown Section'
const sectionGroups = groupsBySection.get(sectionId) ?? []
@@ -116,7 +116,6 @@ export function FamilyLegacyImportPage() {
{meta?.instructions_html ? (
<div
className="small"
// eslint-disable-next-line react/no-danger -- API-provided HTML when present
dangerouslySetInnerHTML={{ __html: meta.instructions_html }}
/>
) : (
+1 -1
View File
@@ -182,7 +182,7 @@ export function GradingDecisionsPage() {
totals.Other += 1
}
if (Boolean(row.is_trophy)) {
if (row.is_trophy) {
totals.Trophy += 1
gender.trophy[bucket] += 1
}
+566 -39
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { ParentParityShell } from './ParentParityShell'
import {
fetchAttendanceReportForm,
@@ -9,7 +10,9 @@ import {
fetchParentRegistrationOverview,
submitParentAttendanceReport,
submitContactMessage,
updateParentEnrollments,
} from '../../api/session'
import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears'
import type {
ParentAttendanceReportFormResponse,
ParentEnrollmentStudent,
@@ -18,6 +21,7 @@ import type {
ParentRegistrationOverviewResponse,
} from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
import { useSchoolYear } from '../../context/SchoolYearContext'
/** `Views/parent/contact.php` — POST /api/v1/contact */
export function ParentContactPage() {
@@ -152,21 +156,36 @@ export function ParentContactPage() {
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
export function ParentEnrollClassesPage() {
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
const [year, setYear] = useState<string | null>(null)
const [dates, setDates] = useState<{
withdrawalDeadline?: string | null
lastDayOfRegistration?: string | null
schoolStartDate?: string | null
}>({})
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
const [actions, setActions] = useState<Record<number, 'enroll' | 'withdraw'>>({})
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchParentEnrollments(null)
const requestedYear = new URLSearchParams(window.location.search).get('school_year')
const data = await fetchParentEnrollments(requestedYear)
if (cancelled) return
setSchoolYears(data.schoolYears ?? [])
setYear(data.selectedYear ?? null)
setDates({
withdrawalDeadline: data.withdrawalDeadline,
lastDayOfRegistration: data.lastDayOfRegistration,
schoolStartDate: data.schoolStartDate,
})
setStudents(data.students ?? [])
setActions({})
setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
@@ -179,70 +198,344 @@ export function ParentEnrollClassesPage() {
}
}, [])
const counts = useMemo(() => {
let enrollable = 0
let withdrawable = 0
for (const student of students) {
if (canEnroll(student)) enrollable += 1
if (canWithdraw(student)) withdrawable += 1
}
return { enrollable, withdrawable }
}, [students])
async function loadYear(nextYear: string | null) {
setLoading(true)
setError(null)
setMessage(null)
try {
const data = await fetchParentEnrollments(nextYear)
setSchoolYears(data.schoolYears ?? [])
setYear(data.selectedYear ?? nextYear)
setDates({
withdrawalDeadline: data.withdrawalDeadline,
lastDayOfRegistration: data.lastDayOfRegistration,
schoolStartDate: data.schoolStartDate,
})
setStudents(data.students ?? [])
setActions({})
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
} finally {
setLoading(false)
}
}
function setAction(studentId: number, action: 'enroll' | 'withdraw' | null) {
setActions((current) => {
const next = { ...current }
if (action) next[studentId] = action
else delete next[studentId]
return next
})
}
async function onSubmit(e: FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
setMessage(null)
try {
const enroll = Object.entries(actions)
.filter(([, action]) => action === 'enroll')
.map(([id]) => Number(id))
const withdraw = Object.entries(actions)
.filter(([, action]) => action === 'withdraw')
.map(([id]) => Number(id))
if (enroll.length === 0 && withdraw.length === 0) {
setError('Choose at least one enrollment or withdrawal action.')
return
}
const result = await updateParentEnrollments({ enroll, withdraw })
const enrolledCount = result.enrolled?.length ?? 0
const withdrawnCount = result.withdrawn?.length ?? 0
await loadYear(year)
setMessage(`Saved ${enrolledCount} enrollment request(s) and ${withdrawnCount} withdrawal request(s).`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not save enrollment changes.')
} finally {
setSaving(false)
}
}
return (
<ParentParityShell
title="Enroll in classes"
ciViewFile="enroll_classes.php"
legacyCiRoutes={['/parent/enroll_classes']}
>
{message ? <div className="alert alert-success small">{message}</div> : null}
{error ? <div className="alert alert-warning small">{error}</div> : null}
{loading ? (
<p className="text-muted small">Loading</p>
) : (
<>
<p className="small text-muted mb-2">
School year: <code>{year ?? '—'}</code>. Updates use{' '}
<code>POST /api/v1/parents/enrollments</code> with enroll / withdraw payloads.
</p>
<form onSubmit={onSubmit}>
<div className="row g-3 align-items-end mb-3">
<div className="col-md-4">
<label className="form-label" htmlFor="enrollment_school_year">
School Year
</label>
<select
id="enrollment_school_year"
className="form-select"
value={year ?? ''}
onChange={(e) => void loadYear(e.target.value || null)}
>
{year && !schoolYears.some((item) => item.school_year === year) ? (
<option value={year}>{year}</option>
) : null}
{schoolYears.map((item) => (
<option key={item.school_year} value={item.school_year}>
{item.school_year}
</option>
))}
</select>
</div>
<div className="col-md-8">
<div className="d-flex flex-wrap gap-2">
<button
type="button"
className="btn btn-outline-success"
onClick={() => {
const next: Record<number, 'enroll' | 'withdraw'> = { ...actions }
students.forEach((student) => {
if (canEnroll(student)) next[student.id] = 'enroll'
})
setActions(next)
}}
disabled={counts.enrollable === 0 || saving}
>
Enroll all available
</button>
<button
type="button"
className="btn btn-outline-danger"
onClick={() => {
const next: Record<number, 'enroll' | 'withdraw'> = { ...actions }
students.forEach((student) => {
if (canWithdraw(student)) next[student.id] = 'withdraw'
})
setActions(next)
}}
disabled={counts.withdrawable === 0 || saving}
>
Withdraw all eligible
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => setActions({})}
disabled={Object.keys(actions).length === 0 || saving}
>
Reset choices
</button>
</div>
</div>
</div>
<div className="row g-3 mb-3">
<div className="col-md-4">
<div className="border rounded p-3 h-100">
<div className="small text-muted">Last day of registration</div>
<div className="fw-semibold">{formatDate(dates.lastDayOfRegistration)}</div>
</div>
</div>
<div className="col-md-4">
<div className="border rounded p-3 h-100">
<div className="small text-muted">Withdrawal deadline</div>
<div className="fw-semibold">{formatDate(dates.withdrawalDeadline)}</div>
</div>
</div>
<div className="col-md-4">
<div className="border rounded p-3 h-100">
<div className="small text-muted">School start date</div>
<div className="fw-semibold">{formatDate(dates.schoolStartDate)}</div>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table table-sm table-bordered">
<table className="table table-sm table-bordered align-middle">
<thead>
<tr>
<th>Student</th>
<th>Class</th>
<th>Status</th>
<th className="text-center">Enroll</th>
<th className="text-center">Withdraw</th>
<th>Choice</th>
</tr>
</thead>
<tbody>
{students.length === 0 ? (
<tr>
<td colSpan={2} className="text-muted">
<td colSpan={6} className="text-muted">
No students for this overview.
</td>
</tr>
) : (
students.map((s) => (
<tr key={s.id}>
students.map((student) => {
const selected = actions[student.id] ?? null
const enrollEnabled = canEnroll(student)
const withdrawEnabled = canWithdraw(student)
return (
<tr key={student.id}>
<td>
{s.firstname} {s.lastname}
<div className="fw-semibold">
{student.firstname} {student.lastname}
</div>
<div className="small text-muted">
{student.registration_grade ? `Grade ${student.registration_grade}` : 'Grade not set'}
{student.gender ? ` · ${student.gender}` : ''}
</div>
</td>
<td>{s.class_section ?? ''}</td>
<td>{student.class_section ?? 'Class not Assigned'}</td>
<td>{statusBadge(student.enrollment_status ?? student.admission_status)}</td>
<td className="text-center">
<button
type="button"
className={`btn btn-sm ${selected === 'enroll' ? 'btn-success' : 'btn-outline-success'}`}
onClick={() => setAction(student.id, selected === 'enroll' ? null : 'enroll')}
disabled={!enrollEnabled || saving}
>
Enroll
</button>
</td>
<td className="text-center">
<button
type="button"
className={`btn btn-sm ${selected === 'withdraw' ? 'btn-danger' : 'btn-outline-danger'}`}
onClick={() => setAction(student.id, selected === 'withdraw' ? null : 'withdraw')}
disabled={!withdrawEnabled || saving}
>
Withdraw
</button>
</td>
<td>{choiceBadge(selected)}</td>
</tr>
))
)
})
)}
</tbody>
</table>
</div>
{schoolYears.length > 1 ? (
<p className="small mb-0">Additional years in response: {schoolYears.length}</p>
) : null}
</>
<div className="d-flex flex-wrap gap-2 justify-content-end mt-3">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => void loadYear(year)}
disabled={saving}
>
Refresh
</button>
<button type="submit" className="btn btn-success" disabled={saving || Object.keys(actions).length === 0}>
{saving ? 'Saving…' : 'Submit changes'}
</button>
</div>
</form>
)}
</ParentParityShell>
)
}
function normalizedStatus(student: ParentEnrollmentStudent): string {
return String(student.enrollment_status ?? student.admission_status ?? 'not enrolled').trim().toLowerCase()
}
function canEnroll(student: ParentEnrollmentStudent): boolean {
if (student.disable_enroll) return false
return ['not enrolled', 'withdrawn', 'refund pending'].includes(normalizedStatus(student))
}
function canWithdraw(student: ParentEnrollmentStudent): boolean {
return ['admission under review', 'payment pending', 'enrolled', 'waitlist'].includes(normalizedStatus(student))
}
function formatDate(value?: string | null): string {
if (!value) return 'Not set'
return value
}
function statusBadge(status?: string | null) {
const value = String(status ?? 'not enrolled').trim() || 'not enrolled'
const normalized = value.toLowerCase()
let className = 'bg-secondary'
if (normalized === 'enrolled') className = 'bg-success'
else if (normalized === 'payment pending') className = 'bg-info text-dark'
else if (normalized === 'admission under review' || normalized === 'waitlist') className = 'bg-warning text-dark'
else if (normalized === 'withdraw under review' || normalized === 'refund pending') className = 'bg-warning text-dark'
else if (normalized === 'withdrawn' || normalized === 'denied') className = 'bg-danger'
return <span className={`badge ${className}`}>{value}</span>
}
function choiceBadge(action: 'enroll' | 'withdraw' | null) {
if (action === 'enroll') return <span className="badge bg-success">Enroll requested</span>
if (action === 'withdraw') return <span className="badge bg-danger">Withdraw requested</span>
return <span className="text-muted small">No change</span>
}
function formatCurrency(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
function parseCurrency(value: number | string | null | undefined): number {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string') return 0
const parsed = Number(value.replace(/[$,]/g, ''))
return Number.isFinite(parsed) ? parsed : 0
}
function invoiceStatusClass(status?: string | null): string {
const normalized = String(status ?? '').trim().toLowerCase()
if (normalized === 'paid') return 'bg-success-subtle text-success-emphasis border border-success-subtle'
if (normalized.includes('partial')) return 'bg-warning-subtle text-warning-emphasis border border-warning-subtle'
if (normalized === 'pending' || normalized === 'unpaid') return 'bg-danger-subtle text-danger-emphasis border border-danger-subtle'
return 'bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle'
}
function paymentTone(balance: number): string {
if (balance > 0) return 'text-danger'
if (balance < 0) return 'text-success'
return 'text-success'
}
function formatDateLabel(value?: string | null): string {
if (!value) return 'No due date'
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
export function ParentPaymentInvoicesPage() {
const { selectedSchoolYearName } = useSchoolYear()
const [error, setError] = useState<string | null>(null)
const [statementError, setStatementError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [statementLoading, setStatementLoading] = useState(false)
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
const [statement, setStatement] = useState<ParentStatement | null>(null)
useEffect(() => {
if (!selectedSchoolYearName) return
let cancelled = false
;(async () => {
setLoading(true)
try {
const data = await fetchParentInvoices(null)
const data = await fetchParentInvoices(selectedSchoolYearName)
if (cancelled) return
setRows(data.invoices ?? [])
setError(null)
@@ -255,53 +548,287 @@ export function ParentPaymentInvoicesPage() {
return () => {
cancelled = true
}
}, [])
}, [selectedSchoolYearName])
useEffect(() => {
if (!selectedSchoolYearName) return
let cancelled = false
;(async () => {
setStatementLoading(true)
setStatement(null)
try {
const data = await fetchParentStatement({ schoolYear: selectedSchoolYearName })
if (cancelled) return
setStatement(data)
setStatementError(null)
} catch (e) {
if (!cancelled) {
setStatement(null)
setStatementError(e instanceof Error ? e.message : 'Failed to load statement.')
}
} finally {
if (!cancelled) setStatementLoading(false)
}
})()
return () => {
cancelled = true
}
}, [selectedSchoolYearName])
const statementLines = useMemo(() => statement?.lines ?? [], [statement])
const totalBalance = useMemo(
() => rows.reduce((sum, inv) => sum + parseCurrency(inv.balance), 0),
[rows],
)
const totalPaid = useMemo(
() => rows.reduce((sum, inv) => sum + parseCurrency(inv.paid_amount), 0),
[rows],
)
const openInvoices = useMemo(
() => rows.filter((inv) => parseCurrency(inv.balance) > 0),
[rows],
)
const paidInvoices = rows.length - openInvoices.length
const nextDueInvoice = useMemo(() => {
return [...openInvoices]
.filter((inv) => inv.due_date)
.sort((a, b) => String(a.due_date).localeCompare(String(b.due_date)))[0]
}, [openInvoices])
const statementTotal = useMemo(
() => statementLines.reduce((sum, line) => sum + parseCurrency(line.amount), 0),
[statementLines],
)
return (
<ParentParityShell
title="Payments & invoices"
ciViewFile="payment_view.php"
legacyCiRoutes={['/parent/payment']}
fullWidth
>
{error ? <div className="alert alert-warning small">{error}</div> : null}
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<div className="text-uppercase text-success fw-semibold small mb-1">Family account</div>
<h3 className="h4 mb-1">Payment overview</h3>
<p className="text-muted mb-0">
{selectedSchoolYearName ? `School year ${selectedSchoolYearName}` : 'School year not selected'}
</p>
</div>
<Link className="btn btn-success" to="/app/parent/payment-redirect">
Make a payment
</Link>
</div>
<div className="row g-3 mb-4">
<div className="col-md-4">
<div className="border rounded-3 p-3 h-100 bg-white">
<div className="small text-muted">Current balance</div>
<div className={`display-6 fw-semibold ${paymentTone(statement?.current_balance ?? totalBalance)}`}>
${formatCurrency(statement?.current_balance ?? totalBalance)}
</div>
<div className="small text-muted">Across all visible invoices and statement items</div>
</div>
</div>
<div className="col-md-4">
<div className="border rounded-3 p-3 h-100 bg-white">
<div className="small text-muted">Invoices</div>
<div className="display-6 fw-semibold text-dark">{rows.length}</div>
<div className="small text-muted">
{openInvoices.length} open, {paidInvoices} paid · ${formatCurrency(totalPaid)} received
</div>
</div>
</div>
<div className="col-md-4">
<div className="border rounded-3 p-3 h-100 bg-white">
<div className="small text-muted">Next due date</div>
<div className="h3 fw-semibold mb-1">{formatDateLabel(nextDueInvoice?.due_date)}</div>
<div className="small text-muted">
{nextDueInvoice ? nextDueInvoice.invoice_number ?? `Invoice #${nextDueInvoice.id}` : 'No open due dates'}
</div>
</div>
</div>
</div>
<section className="mb-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h3 className="h5 mb-1">Invoices</h3>
<p className="text-muted small mb-0">Review balances and payment status for each invoice.</p>
</div>
<span className="badge bg-light text-dark border">${formatCurrency(totalBalance)} visible balance</span>
</div>
{loading ? (
<p className="text-muted small">Loading</p>
<div className="border rounded-3 p-4 text-muted small bg-white">Loading invoices</div>
) : rows.length === 0 ? (
<div className="border rounded-3 p-4 text-center bg-white">
<div className="fw-semibold">No invoices found</div>
<div className="text-muted small">There are no invoices for this school year.</div>
</div>
) : (
<div className="d-grid gap-3">
{rows.map((inv) => {
const balance = parseCurrency(inv.balance)
const totalAmount = parseCurrency(inv.total_amount)
const paidAmount = parseCurrency(inv.paid_amount)
const invoiceLabel = inv.invoice_number ?? `Invoice #${inv.id}`
const payments = inv.payments ?? []
return (
<div className="border rounded-3 p-3 bg-white" key={inv.id}>
<div className="row g-3 align-items-center">
<div className="col-lg-3">
<div className="small text-muted">Invoice</div>
<div className="fw-semibold">{invoiceLabel}</div>
<div className="small text-muted">Record #{inv.id}</div>
</div>
<div className="col-6 col-lg-2">
<div className="small text-muted">Invoice total</div>
<div className="h5 mb-0 text-dark">${formatCurrency(totalAmount)}</div>
</div>
<div className="col-6 col-lg-2">
<div className="small text-muted">Paid</div>
<div className="h5 mb-0 text-success">${formatCurrency(paidAmount)}</div>
</div>
<div className="col-6 col-lg-2">
<div className="small text-muted">Balance</div>
<div className={`h5 mb-0 ${paymentTone(balance)}`}>${formatCurrency(balance)}</div>
</div>
<div className="col-6 col-lg-1">
<div className="small text-muted">Due</div>
<div className="fw-semibold">{formatDateLabel(inv.due_date)}</div>
</div>
<div className="col-md-6 col-lg-1">
<span className={`badge rounded-pill px-3 py-2 ${invoiceStatusClass(inv.status)}`}>
{inv.status ?? 'Unknown'}
</span>
</div>
<div className="col-md-6 col-lg-1 text-md-end">
{balance > 0 ? (
<Link
className="btn btn-outline-success btn-sm"
to={`/app/parent/payment-redirect?invoice_id=${encodeURIComponent(String(inv.id))}`}
>
Pay invoice
</Link>
) : (
<span className="text-success small fw-semibold">Paid</span>
)}
</div>
</div>
<div className="border-top mt-3 pt-3">
<div className="small fw-semibold mb-2">Payments from payment records</div>
{payments.length === 0 ? (
<div className="small text-muted">No posted payments found for this invoice.</div>
) : (
<div className="table-responsive">
<table className="table table-sm table-striped">
<table className="table table-sm mb-0">
<thead>
<tr>
<th>#</th>
<th>Invoice</th>
<th>Balance</th>
<th>Date</th>
<th>Amount</th>
<th>Method</th>
<th>Status</th>
<th>Due</th>
<th>Transaction</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
{payments.map((payment) => (
<tr key={payment.id}>
<td>{payment.payment_date ? formatDateLabel(payment.payment_date) : 'No payment date'}</td>
<td>${formatCurrency(payment.paid_amount)}</td>
<td>{payment.payment_method ?? '—'}</td>
<td>{payment.status ?? '—'}</td>
<td>{payment.transaction_id ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</section>
<section className="border-top mt-4 pt-4">
<div className="d-flex flex-wrap justify-content-between gap-3 mb-3">
<div>
<h3 className="h5 mb-1">Parent statement</h3>
<p className="text-muted small mb-0">
Opening balances from closed years are shown separately from new-year charges so they are not double-counted.
</p>
</div>
<div className="text-md-end">
<div className="small text-muted">Statement total</div>
<div className="h4 mb-0">${formatCurrency(statementTotal)}</div>
</div>
</div>
{statementError ? <div className="alert alert-warning small">{statementError}</div> : null}
{statementLoading ? (
<div className="border rounded-3 p-4 text-muted small bg-white">Loading statement</div>
) : (
<>
<div className="row g-3 mb-3">
<div className="col-md-6">
<div className="border rounded-3 p-3 bg-light h-100">
<div className="small text-muted">Opening balance from prior year</div>
<div className="h4 mb-1">${formatCurrency(statement?.opening_balance)}</div>
<div className="small text-muted">
The old-balance invoice is the collectible item. This label is audit context, not a second charge.
</div>
</div>
</div>
<div className="col-md-6">
<div className="border rounded-3 p-3 bg-light h-100">
<div className="small text-muted">Current balance</div>
<div className={`h4 mb-1 ${paymentTone(statement?.current_balance ?? 0)}`}>
${formatCurrency(statement?.current_balance)}
</div>
<div className="small text-muted">Includes current statement activity for this account.</div>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table table-sm align-middle bg-white border">
<thead>
<tr>
<td colSpan={5} className="text-muted">
No invoices.
<th>Description</th>
<th>Source year</th>
<th>Invoice</th>
<th className="text-end">Amount</th>
</tr>
</thead>
<tbody>
{statementLines.length === 0 ? (
<tr>
<td colSpan={4} className="text-muted">
No statement lines found.
</td>
</tr>
) : (
rows.map((inv) => (
<tr key={inv.id}>
<td>{inv.id}</td>
<td>{inv.invoice_number ?? '—'}</td>
<td>
{typeof inv.balance === 'number' ? inv.balance.toFixed(2) : '—'}
</td>
<td>{inv.status ?? '—'}</td>
<td>{inv.due_date ?? '—'}</td>
statementLines.map((line, index) => (
<tr key={`${line.label}-${line.invoice_id ?? index}`}>
<td>{line.label}</td>
<td>{line.source_school_year ?? '—'}</td>
<td>{line.invoice_id ?? '—'}</td>
<td className={`text-end ${paymentTone(line.amount)}`}>${formatCurrency(line.amount)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
)}
</section>
</ParentParityShell>
)
}
@@ -515,7 +1042,7 @@ export function ParentReportAttendancePage() {
<select name="student_id" className="form-control" required>
<option value="">Select student</option>
{Array.isArray(data?.students)
? data.students.map((student: any) => (
? data.students.map((student: { id: number; firstname: string; lastname: string }) => (
<option key={student.id} value={student.id}>
{student.firstname} {student.lastname}
</option>
+39 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
@@ -49,23 +49,32 @@ export function ManualPayPage() {
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [editPaymentId, setEditPaymentId] = useState<number | string | null>(null)
const loadSeq = useRef(0)
const activeTerm = termFromUrl
const load = useCallback(
(term: string) => {
const requestId = ++loadSeq.current
if (!term.trim()) {
setData(null)
setLoading(false)
return
}
setLoading(true)
setError(null)
fetchManualPayPage({ search_term: term.trim() })
.then(setData)
.catch((e: unknown) =>
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.'),
)
.finally(() => setLoading(false))
.then((payload) => {
if (requestId === loadSeq.current) setData(payload)
})
.catch((e: unknown) => {
if (requestId === loadSeq.current) {
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.')
}
})
.finally(() => {
if (requestId === loadSeq.current) setLoading(false)
})
},
[],
)
@@ -76,12 +85,11 @@ export function ManualPayPage() {
}, [termFromUrl, load])
useEffect(() => {
let t: ReturnType<typeof setTimeout>
if (searchInput.trim().length < 2) {
setSuggestions([])
return
}
t = setTimeout(() => {
const t = setTimeout(() => {
fetchManualPaySuggest(searchInput.trim())
.then((rows) => {
setSuggestions(
@@ -104,6 +112,21 @@ export function ManualPayPage() {
navigate({ pathname: '/app/administrator/payment/manual-pay', search: `?search_term=${encodeURIComponent(q)}` })
}
function clearSearch() {
loadSeq.current += 1
setSearchInput('')
setData(null)
setError(null)
setSuggestions([])
setSuggestOpen(false)
setLoading(false)
setShowAddModal(false)
setEditPaymentId(null)
if (previewBlobUrl) URL.revokeObjectURL(previewBlobUrl)
setPreviewBlobUrl(null)
navigate({ pathname: '/app/administrator/payment/manual-pay', search: '' })
}
function pickSuggestion(text: string) {
setSearchInput(text)
setSuggestOpen(false)
@@ -184,6 +207,14 @@ export function ManualPayPage() {
<button className="btn btn-primary" type="submit" disabled={loading}>
Search
</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={clearSearch}
disabled={loading && !searchInput && !data}
>
Clear
</button>
</div>
{suggestOpen && suggestions.length > 0 ? (
<div className="list-group position-absolute w-100 shadow-sm" style={{ zIndex: 10 }}>
@@ -112,7 +112,7 @@ export function AdminPrintRequestsPage() {
<button
type="button"
className="btn btn-link btn-sm p-0"
onClick={() => openPrintRequestFile(encodeURIComponent(fp))}
onClick={() => openPrintRequestFile(id as number | string)}
>
{fp}
</button>
@@ -6,7 +6,8 @@ import {
fetchReportCardCompleteness,
fetchReportCardMeta,
fetchReportCardStudentHtml,
openReportCardDownload,
openReportCardClassDownload,
openReportCardStudentDownload,
reportCardClassUrl,
reportCardStudentUrl,
type ReportCardMeta,
@@ -118,14 +119,12 @@ export function ReportCardManagementPage() {
async function downloadStudent() {
if (!studentId || !schoolYear) return
const url = reportCardStudentUrl(studentId, {
try {
await openReportCardStudentDownload(studentId, {
school_year: schoolYear,
semester,
download: true,
report_date: reportDate || undefined,
})
try {
await openReportCardDownload(url)
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
}
@@ -133,14 +132,12 @@ export function ReportCardManagementPage() {
async function downloadClass() {
if (!classId || !schoolYear) return
const url = reportCardClassUrl(classId, {
try {
await openReportCardClassDownload(classId, {
school_year: schoolYear,
semester,
download: true,
report_date: reportDate || undefined,
})
try {
await openReportCardDownload(url)
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
}
+1 -1
View File
@@ -113,7 +113,7 @@ export function SlipPreviewListPage() {
function toggleDate(dateKey: string) {
setExpandedDates((prev) => {
const next = new Set(prev)
next.has(dateKey) ? next.delete(dateKey) : next.add(dateKey)
if (next.has(dateKey)) next.delete(dateKey); else next.add(dateKey)
return next
})
}
+30 -5
View File
@@ -1,22 +1,27 @@
import { type FormEvent, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { createStaff, fetchStaffFormOptions, type RoleOption } from '../../api/staff'
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function StaffCreatePage() {
const navigate = useNavigate()
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const [roles, setRoles] = useState<RoleOption[]>([])
const [firstname, setFirstname] = useState('')
const [lastname, setLastname] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [roleId, setRoleId] = useState<number>(0)
const [status, setStatus] = useState('active')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await fetchStaffFormOptions()
const data = await fetchStaffFormOptions({ school_year: selectedSchoolYearName ?? undefined })
if (cancelled) return
const list = data.roles ?? []
setRoles(list)
@@ -28,17 +33,21 @@ export function StaffCreatePage() {
return () => {
cancelled = true
}
}, [])
}, [selectedSchoolYearName])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!selectedSchoolYearName) {
setError('Please select a school year.')
return
}
if (!roleId) {
setError('Please select a role.')
return
}
try {
await createStaff({ firstname, lastname, email, password, role_id: roleId })
navigate('/app/staff')
await createStaff({ firstname, lastname, email, phone, password, role_id: roleId, status, school_year: selectedSchoolYearName })
navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to create staff.')
}
@@ -47,6 +56,11 @@ export function StaffCreatePage() {
return (
<div className="container mt-4">
<h2>Add Staff Member</h2>
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="text-muted">School year: {selectedSchoolYearName ?? '—'}</div>
<SchoolYearSelector compact />
</div>
<ReadOnlyBanner />
{error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={(e) => void onSubmit(e)}>
<div className="mb-3">
@@ -89,6 +103,10 @@ export function StaffCreatePage() {
required
/>
</div>
<div className="mb-3">
<label className="form-label">Phone</label>
<input type="tel" className="form-control" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Role</label>
<select
@@ -104,7 +122,14 @@ export function StaffCreatePage() {
))}
</select>
</div>
<button type="submit" className="btn btn-success">
<div className="mb-3">
<label className="form-label">Status</label>
<select className="form-select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<button type="submit" className="btn btn-success" disabled={isReadOnlyYear || !selectedSchoolYearName}>
Create
</button>
</form>
+17 -5
View File
@@ -1,10 +1,13 @@
import { type FormEvent, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { fetchStaffMember, updateStaff } from '../../api/staff'
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function StaffEditPage() {
const { staffId } = useParams()
const navigate = useNavigate()
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const id = Number(staffId)
const [firstname, setFirstname] = useState('')
const [lastname, setLastname] = useState('')
@@ -17,7 +20,7 @@ export function StaffEditPage() {
let cancelled = false
;(async () => {
try {
const data = await fetchStaffMember(id)
const data = await fetchStaffMember(id, { school_year: selectedSchoolYearName ?? undefined })
if (cancelled) return
const u = (data.user ?? data.staff ?? {}) as Record<string, unknown>
setFirstname(String(u.firstname ?? ''))
@@ -32,14 +35,18 @@ export function StaffEditPage() {
return () => {
cancelled = true
}
}, [id])
}, [id, selectedSchoolYearName])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!id) return
try {
await updateStaff(id, { firstname, lastname, email, phone })
navigate('/app/staff')
if (!selectedSchoolYearName) {
setError('Please select a school year.')
return
}
await updateStaff(id, { firstname, lastname, email, phone, school_year: selectedSchoolYearName })
navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to update staff.')
}
@@ -48,6 +55,11 @@ export function StaffEditPage() {
return (
<div className="container mt-4">
<h2>Edit Staff Member</h2>
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="text-muted">School year: {selectedSchoolYearName ?? '—'}</div>
<SchoolYearSelector compact />
</div>
<ReadOnlyBanner />
{error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={(e) => void onSubmit(e)}>
<div className="mb-3">
@@ -91,7 +103,7 @@ export function StaffEditPage() {
placeholder="Enter phone number"
/>
</div>
<button type="submit" className="btn btn-primary">
<button type="submit" className="btn btn-primary" disabled={isReadOnlyYear || !selectedSchoolYearName}>
Update
</button>
</form>
+80 -19
View File
@@ -1,51 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { CiAcademicFilter } from '../../components/CiPartials'
import { fetchStaffIndex, type StaffListRow } from '../../api/staff'
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function StaffIndexPage() {
const [searchParams, setSearchParams] = useSearchParams()
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const [rows, setRows] = useState<StaffListRow[]>([])
const [issuesCount, setIssuesCount] = useState(0)
const [schoolYear, setSchoolYear] = useState<string | null>(null)
const [schoolYears, setSchoolYears] = useState<string[]>([])
const [semester, setSemester] = useState<string | null>(null)
const [search, setSearch] = useState(searchParams.get('search') ?? '')
const [status, setStatus] = useState(searchParams.get('status') ?? '')
const [role, setRole] = useState(searchParams.get('role') ?? '')
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
const sy = selectedSchoolYearName ?? searchParams.get('school_year') ?? undefined
try {
const sy = searchParams.get('school_year') ?? undefined
const sem = searchParams.get('semester') ?? undefined
const data = await fetchStaffIndex({ school_year: sy, semester: sem })
const data = await fetchStaffIndex({
school_year: sy,
semester: sem,
search: searchParams.get('search') ?? undefined,
status: searchParams.get('status') ?? undefined,
role: searchParams.get('role') ?? undefined,
})
setRows(data.staff ?? [])
setIssuesCount(Number(data.issues_count ?? 0))
setSchoolYear(data.school_year ?? null)
setSemester(data.semester ?? null)
setSchoolYears(data.schoolYears ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load staff.')
}
}, [searchParams])
}, [searchParams, selectedSchoolYearName])
useEffect(() => {
if (!selectedSchoolYearName) return
setSearchParams((current) => {
const next = new URLSearchParams(current)
next.delete('school_year_id')
next.delete('year')
next.set('school_year', selectedSchoolYearName)
return next.toString() === current.toString() ? current : next
}, { replace: true })
}, [selectedSchoolYearName, setSearchParams])
useEffect(() => {
void load()
}, [load])
function applyFilters() {
const next = new URLSearchParams()
if (selectedSchoolYearName) next.set('school_year', selectedSchoolYearName)
if (semester) next.set('semester', semester)
if (search.trim()) next.set('search', search.trim())
if (status) next.set('status', status)
if (role) next.set('role', role)
setSearchParams(next)
}
const selectedYear = selectedSchoolYearName ?? schoolYear ?? ''
const selectedQuery = selectedYear ? `?school_year=${encodeURIComponent(selectedYear)}` : ''
return (
<div className="container-fluid mt-4">
<h2 className="text-center mt-4 mb-3">Staff List</h2>
<CiAcademicFilter
schoolYears={schoolYears.length > 0 ? schoolYears : schoolYear ? [schoolYear] : []}
selectedYear={searchParams.get('school_year') ?? schoolYear ?? undefined}
selectedSemester={searchParams.get('semester') ?? semester ?? undefined}
onApply={({ schoolYear: y, semester: sem }) => {
const next = new URLSearchParams()
if (y) next.set('school_year', y)
if (sem) next.set('semester', sem)
setSearchParams(next)
}}
/>
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 className="mb-1">Staff List</h2>
<div className="text-muted small">School year: {selectedYear || '—'}</div>
</div>
<div className="d-flex flex-wrap gap-2 align-items-center">
<SchoolYearSelector compact />
{!isReadOnlyYear ? (
<Link className="btn btn-primary btn-sm" to={`/app/staff/create${selectedQuery}`}>Add Staff</Link>
) : null}
</div>
</div>
<ReadOnlyBanner />
<div className="row g-2 align-items-end mb-3">
<div className="col-md-4">
<label className="form-label">Search</label>
<input className="form-control" value={search} onChange={(event) => setSearch(event.target.value)} />
</div>
<div className="col-md-2">
<label className="form-label">Semester</label>
<input className="form-control" value={semester ?? ''} onChange={(event) => setSemester(event.target.value)} placeholder="Fall" />
</div>
<div className="col-md-2">
<label className="form-label">Role</label>
<input className="form-control" value={role} onChange={(event) => setRole(event.target.value)} />
</div>
<div className="col-md-2">
<label className="form-label">Status</label>
<select className="form-select" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="">Any</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="col-md-2">
<button className="btn btn-outline-primary w-100" type="button" onClick={applyFilters}>Apply</button>
</div>
</div>
{issuesCount > 0 ? (
<div className="alert alert-warning" role="alert">
@@ -83,7 +144,7 @@ export function StaffIndexPage() {
<td>{s.active_role ?? ''}</td>
<td>{s.class_section ?? ''}</td>
<td>
<Link className="btn btn-sm btn-warning" to={`/app/staff/edit/${s.id}`}>
<Link className="btn btn-sm btn-warning" to={`/app/staff/edit/${s.id}${selectedQuery}`}>
Edit
</Link>
</td>
@@ -70,6 +70,7 @@ function buildHistoryQueryString(qs: string): string {
if (v === '' || v == null) emptyKeys.push(k)
})
emptyKeys.forEach((k) => p.delete(k))
p.delete('class_section_id')
if (!p.has('per_page')) p.set('per_page', '100')
if (!p.has('sort_by')) p.set('sort_by', 'week_start')
if (!p.has('sort_dir')) p.set('sort_dir', 'desc')
@@ -106,7 +107,6 @@ export function TeacherClassProgressHistoryPage() {
const [draftWeekStart, setDraftWeekStart] = useState(() => search.get('week_start') ?? '')
const [draftWeekEnd, setDraftWeekEnd] = useState(() => search.get('week_end') ?? '')
const [draftSection, setDraftSection] = useState(() => search.get('class_section_id') ?? '')
const [draftStatus, setDraftStatus] = useState(() => search.get('status') ?? '')
const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc')
@@ -114,22 +114,10 @@ export function TeacherClassProgressHistoryPage() {
const p = new URLSearchParams(buildHistoryQueryString(qs))
setDraftWeekStart(p.get('week_start') ?? '')
setDraftWeekEnd(p.get('week_end') ?? '')
setDraftSection(p.get('class_section_id') ?? '')
setDraftStatus(p.get('status') ?? '')
setDraftSortDir(p.get('sort_dir') || 'desc')
}, [qs])
const sectionOptions = useMemo(() => {
const m = new Map<number, string>()
for (const r of items) {
const id = Number(r.class_section_id ?? 0)
if (!id) continue
const name = String(r.class_section_name ?? '').trim() || `Section ${id}`
if (!m.has(id)) m.set(id, name)
}
return [...m.entries()].sort((a, b) => a[0] - b[0])
}, [items])
function applyFilters(e: FormEvent) {
e.preventDefault()
const next = new URLSearchParams()
@@ -138,7 +126,6 @@ export function TeacherClassProgressHistoryPage() {
next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc')
if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim())
if (draftWeekEnd.trim()) next.set('week_end', draftWeekEnd.trim())
if (draftSection.trim()) next.set('class_section_id', draftSection.trim())
if (draftStatus.trim()) next.set('status', draftStatus.trim())
next.delete('page')
setSearch(next, { replace: true })
@@ -147,7 +134,6 @@ export function TeacherClassProgressHistoryPage() {
function resetFilters() {
setDraftWeekStart('')
setDraftWeekEnd('')
setDraftSection('')
setDraftStatus('')
const next = new URLSearchParams()
next.set('per_page', '100')
@@ -220,24 +206,6 @@ export function TeacherClassProgressHistoryPage() {
onChange={(e) => setDraftWeekEnd(e.target.value)}
/>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-sec">
Section
</label>
<select
id="hist-sec"
className="form-select form-select-sm"
value={draftSection}
onChange={(e) => setDraftSection(e.target.value)}
>
<option value="">All</option>
{sectionOptions.map(([id, name]) => (
<option key={id} value={String(id)}>
{name}
</option>
))}
</select>
</div>
<div className="col-md-3 col-lg-2">
<label className="form-label small mb-0" htmlFor="hist-st">
Status
@@ -1,5 +1,5 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { ApiHttpError, apiFetch } from '../../api/http'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
@@ -110,16 +110,7 @@ function appendArray(fd: FormData, key: string, values: string[]) {
/** `teacher/class_progress_submit.php` — weekly progress form; POST `/api/v1/teacher/class-progress-submit`. */
export function TeacherClassProgressSubmitPage() {
const [search] = useSearchParams()
const classIdQ = search.get('class_id')
const path = useMemo(() => {
const params = new URLSearchParams()
if (classIdQ) params.set('class_id', classIdQ)
const q = params.toString()
return `/class-progress-submit${q ? `?${q}` : ''}`
}, [classIdQ])
const { data: raw, loading, error, reload } = useTeacherResource<unknown>(path)
const { data: raw, loading, error, reload } = useTeacherResource<unknown>('/class-progress-submit')
const data = useMemo(() => unwrapSubmitForm(raw), [raw])
const subjectSections = data?.subject_sections ?? {}
@@ -132,15 +123,6 @@ export function TeacherClassProgressSubmitPage() {
const unitPickList = useMemo(() => unitChoices(curriculumIslamic), [curriculumIslamic])
const classIds = useMemo(() => {
const s = new Set<number>()
for (const c of classes) {
const id = Number(c.class_id ?? 0)
if (id > 0) s.add(id)
}
return [...s].sort((a, b) => a - b)
}, [classes])
const [weekStart, setWeekStart] = useState('')
const [classSectionId, setClassSectionId] = useState<number | ''>('')
const [covered, setCovered] = useState<Record<string, string>>({})
@@ -172,31 +154,20 @@ export function TeacherClassProgressSubmitPage() {
setSubmitOk(null)
}, [data, defaults.class_section_id, defaults.week_start, sundayOptions])
const resolvedClassId = useMemo(() => {
const fromQ = classIdQ ? Number(classIdQ) : 0
if (fromQ > 0) return fromQ
return Number(defaults.class_id ?? 0)
}, [classIdQ, defaults.class_id])
const sectionsForClass = useMemo(() => {
if (!resolvedClassId) return classes
return classes.filter((c) => Number(c.class_id ?? 0) === resolvedClassId)
}, [classes, resolvedClassId])
useEffect(() => {
if (classSectionId === '' && sectionsForClass.length === 1) {
const only = Number(sectionsForClass[0].class_section_id ?? 0)
if (classSectionId === '' && classes.length === 1) {
const only = Number(classes[0].class_section_id ?? 0)
if (only > 0) setClassSectionId(only)
}
}, [sectionsForClass, classSectionId])
}, [classes, classSectionId])
const canSubmit = useMemo(() => {
if (!weekStart || !classSectionId) return false
if (!weekStart) return false
if (!islamicSelectionValid(islamicRows)) return false
const covIslamic = (covered.islamic ?? '').trim()
if (covIslamic === '') return false
return true
}, [weekStart, classSectionId, islamicRows, covered])
}, [weekStart, islamicRows, covered])
const updateIslamicRow = useCallback((idx: number, patch: Partial<UnitRow>) => {
setIslamicRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
@@ -209,7 +180,7 @@ export function TeacherClassProgressSubmitPage() {
const handleSubmit = useCallback(async () => {
setSubmitError(null)
setSubmitOk(null)
if (!canSubmit || classSectionId === '') return
if (!canSubmit) return
const unitIslamic: string[] = []
const chapterIslamic: string[] = []
@@ -240,8 +211,9 @@ export function TeacherClassProgressSubmitPage() {
}
const fd = new FormData()
fd.append('class_section_id', String(classSectionId))
fd.append('week_start', weekStart)
if (defaults.school_year) fd.append('school_year', defaults.school_year)
if (defaults.semester) fd.append('semester', defaults.semester)
if (confirmOverwrite) fd.append('confirm_overwrite', '1')
for (const slug of slugs) {
@@ -282,8 +254,9 @@ export function TeacherClassProgressSubmitPage() {
}
}, [
canSubmit,
classSectionId,
weekStart,
defaults.school_year,
defaults.semester,
confirmOverwrite,
slugs,
covered,
@@ -328,26 +301,6 @@ export function TeacherClassProgressSubmitPage() {
</div>
) : null}
{classes.length > 0 && classIds.length > 1 ? (
<div className="d-flex flex-wrap align-items-center gap-2">
<span className="small text-muted me-1">Class:</span>
{classIds.map((id) => {
const label =
classes.find((c) => Number(c.class_id) === id)?.class_name?.trim() || `Class ${id}`
const active = resolvedClassId === id
return (
<Link
key={id}
to={`/app/teacher/class-progress-submit?class_id=${encodeURIComponent(String(id))}`}
className={`btn btn-sm ${active ? 'btn-primary' : 'btn-outline-primary'}`}
>
{label}
</Link>
)
})}
</div>
) : null}
{submitOk ? (
<div className="alert alert-success py-2 mb-0" role="status">
{submitOk}
@@ -382,29 +335,6 @@ export function TeacherClassProgressSubmitPage() {
))}
</select>
</div>
<div className="col-md-6">
<label className="form-label" htmlFor="cp-section">
Class section
</label>
<select
id="cp-section"
className="form-select form-select-sm"
value={classSectionId === '' ? '' : String(classSectionId)}
onChange={(e) => setClassSectionId(e.target.value ? Number(e.target.value) : '')}
>
<option value="">Select section</option>
{sectionsForClass.map((c) => {
const id = Number(c.class_section_id ?? 0)
if (!id) return null
const name = String(c.class_section_name ?? '').trim() || `Section ${id}`
return (
<option key={id} value={id}>
{name}
</option>
)
})}
</select>
</div>
</div>
</section>
@@ -671,7 +601,7 @@ export function TeacherClassProgressSubmitPage() {
</button>
{!canSubmit ? (
<span className="small text-muted">
Choose section, week, Islamic unit/chapter, and material covered for Islamic Studies.
Choose week, Islamic unit/chapter, and material covered for Islamic Studies.
</span>
) : null}
</div>
+25 -11
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom'
import { fetchAdministratorCalendarEvents, openProtectedApiFile } from '../../api/session'
import type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types'
import { TeacherShell } from './TeacherShell'
@@ -128,8 +128,18 @@ type FrontendMeResponse = {
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null) {
return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : null
function teacherExamStoredFileName(filename?: string | null): string | null {
const value = String(filename ?? '').trim()
return /\.(docx?|pdf)$/i.test(value) ? value : null
}
function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null, draftId?: number | null) {
const stored = teacherExamStoredFileName(filename)
if (stored) return `/api/v1/files/exams/${kind}/${encodeURIComponent(stored)}`
if (kind === 'final' && draftId && draftId > 0) {
return `/api/v1/files/exams/final/${encodeURIComponent(String(draftId))}`
}
return null
}
type TeacherAbsenceRow = {
@@ -332,7 +342,9 @@ export function TeacherCompetitionScoresIndexPage() {
<td>{dateLabel}</td>
<td>{isLocked ? 'Yes' : 'No'}</td>
<td>
{isLocked ? (
{compId <= 0 ? (
<span className="text-muted">Unavailable</span>
) : isLocked ? (
<span className="text-muted">Locked</span>
) : (
<Link to={`/app/teacher/competition-scores/${compId}`}>Enter scores</Link>
@@ -353,10 +365,12 @@ export function TeacherCompetitionScoresIndexPage() {
/** `teacher/competition_scores/scores.php` */
export function TeacherCompetitionScoresDetailPage() {
const { competitionId } = useParams()
const path =
competitionId && competitionId !== 'undefined'
? `/competition-scores/${competitionId}`
: '/competition-scores/0'
const id = Number(competitionId ?? 0)
if (!Number.isInteger(id) || id <= 0) {
return <Navigate to="/app/teacher/competition-scores" replace />
}
const path = `/competition-scores/${id}`
return <TeacherApiDumpPage title="Competition scores entry" path={path} />
}
@@ -743,7 +757,7 @@ export function TeacherCalendarPage() {
(row) =>
Boolean(row.extendedProps?.notify_teacher) &&
Boolean(row.extendedProps?.notify_admin) &&
!Boolean(row.extendedProps?.notify_parent),
!row.extendedProps?.notify_parent,
).length
return (
@@ -1279,7 +1293,7 @@ export function TeacherExamDraftsPage() {
) : (
(payload?.drafts ?? []).map((draft) => {
const teacherFile = teacherExamFilePath('teacher', draft.teacher_file)
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file)
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file, draft.id)
return (
<tr key={draft.id}>
<td>
@@ -1340,7 +1354,7 @@ export function TeacherExamDraftsPage() {
) : (
<div className="row g-3">
{(payload?.legacy_exams ?? []).map((draft) => {
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file)
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file, draft.id)
return (
<div key={draft.id} className="col-12 col-xl-6">
<article className="teacher-calendar-agenda-item h-100">
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
/** Laravel API for dev proxy (`/api` → this host). Override: `VITE_PROXY_API=http://localhost:8000 npm run dev`. */
const apiTarget = process.env.VITE_PROXY_API ?? process.env.VITE_API_ORIGIN ?? 'http://localhost:8000'
const apiTarget = process.env.VITE_PROXY_API ?? process.env.VITE_API_URL ?? 'http://localhost:8000'
// https://vite.dev/config/
export default defineConfig({