Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6d2bc007e | |||
| b895c06dc1 | |||
| 4b78aafa65 | |||
| b510f534fd | |||
| b74ddca8b1 | |||
| 8cacd0874f | |||
| 405dc3e379 | |||
| 816f4cfaf0 | |||
| 7282020444 |
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+524
-537
File diff suppressed because it is too large
Load Diff
+28
@@ -73,3 +73,31 @@ body {
|
||||
color: #15654f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.school-year-selector {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.school-year-selector .form-select {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.school-year-selector--compact .form-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.school-year-selector--compact .form-select {
|
||||
width: auto;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.navbar-dark .school-year-selector,
|
||||
.custom-navbar .school-year-selector {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.navbar-dark .school-year-selector .form-label,
|
||||
.custom-navbar .school-year-selector .form-label {
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
+55
-1
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`)
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
|
||||
*/
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||
|
||||
export type AdminNotificationRow = {
|
||||
id?: number
|
||||
@@ -56,6 +56,7 @@ async function postMultipart<T>(path: string, formData: FormData): Promise<T> {
|
||||
const headers = new Headers({ Accept: 'application/json' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
applyStoredSchoolYearHeaders(headers)
|
||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
@@ -136,6 +137,7 @@ export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string
|
||||
const headers = new Headers({ Accept: '*/*' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
applyStoredSchoolYearHeaders(headers)
|
||||
const res = await fetch(apiUrl(apiPath), { headers })
|
||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||
const blob = await res.blob()
|
||||
@@ -335,6 +337,7 @@ export async function downloadFinancialReportCsv(params: {
|
||||
const headers = new Headers()
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
applyStoredSchoolYearHeaders(headers)
|
||||
const res = await fetch(apiUrl(path), { headers })
|
||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||
const blob = await res.blob()
|
||||
@@ -408,6 +411,7 @@ export async function downloadFinancialSummaryPdf(params: { school_year?: string
|
||||
const headers = new Headers()
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
applyStoredSchoolYearHeaders(headers)
|
||||
const res = await fetch(apiUrl(path), { headers })
|
||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||
const raw = await res.blob()
|
||||
|
||||
@@ -2,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
@@ -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,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,7 +2,7 @@
|
||||
* Teacher portal API (`Views/teacher/*` parity).
|
||||
* Laravel should expose matching routes under `/api/v1/teacher/...`.
|
||||
*/
|
||||
import { apiFetch, getStoredToken } from './http'
|
||||
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
|
||||
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
|
||||
@@ -47,6 +47,7 @@ export async function postTeacherMultipart<T>(path: string, formData: FormData):
|
||||
const headers = new Headers({ Accept: 'application/json' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
applyStoredSchoolYearHeaders(headers)
|
||||
|
||||
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export type AuthUser = {
|
||||
id: number
|
||||
name: string
|
||||
roles: Record<string, boolean>
|
||||
permissions?: Record<string, boolean> | string[]
|
||||
class_section_id?: number | null
|
||||
class_section_name?: string | null
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ReadOnlyBanner } from './ReadOnlyBanner'
|
||||
export { ReadOnlyGuard } from './ReadOnlyGuard'
|
||||
export { SchoolYearSelector } from './SchoolYearSelector'
|
||||
export { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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(/\/$/, '')
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ function makeOption(
|
||||
}
|
||||
|
||||
function optionFromRecord(record: SchoolYearRecord): SchoolYearOption | null {
|
||||
return makeOption(record.name, record.status, Boolean(record.is_current), record.id)
|
||||
return makeOption(record.name, record.status, Boolean(record.is_current || record.isCurrent), record.id)
|
||||
}
|
||||
|
||||
function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null {
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user