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_PORT` | `22` | SSH port for the deployment server |
|
||||||
| `SSH_TARGET_DIR` | — | Absolute path on the server where `dist/` contents should be placed |
|
| `SSH_TARGET_DIR` | — | Absolute path on the server where `dist/` contents should be placed |
|
||||||
| `PRODUCTION_URL` | — | Public URL of the production site |
|
| `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
|
## 3. Allow Gitea Actions
|
||||||
|
|
||||||
|
|||||||
+10
-31
@@ -44,14 +44,6 @@ jobs:
|
|||||||
git checkout "${{ github.sha }}"
|
git checkout "${{ github.sha }}"
|
||||||
shell: sh
|
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
|
- name: Install dependencies
|
||||||
run: npm ci --no-audit --no-fund
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
@@ -82,28 +74,13 @@ jobs:
|
|||||||
git checkout "${{ github.sha }}"
|
git checkout "${{ github.sha }}"
|
||||||
shell: sh
|
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
|
- name: Install dependencies
|
||||||
run: npm ci --no-audit --no-fund
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
- name: Build project
|
- name: Build project
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
VITE_API_ORIGIN: ${{ vars.VITE_API_ORIGIN || '' }}
|
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# DEPLOY (manual): to shared hosting via SSH
|
# DEPLOY (manual): to shared hosting via SSH
|
||||||
@@ -112,7 +89,7 @@ jobs:
|
|||||||
name: Deploy to shared hosting
|
name: Deploy to shared hosting
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: alpine:3.20
|
image: node:22-alpine
|
||||||
if: github.ref == 'refs/heads/develop'
|
if: github.ref == 'refs/heads/develop'
|
||||||
needs: [lint, build]
|
needs: [lint, build]
|
||||||
environment:
|
environment:
|
||||||
@@ -134,16 +111,18 @@ jobs:
|
|||||||
git checkout "${{ github.sha }}"
|
git checkout "${{ github.sha }}"
|
||||||
shell: sh
|
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
|
- name: Install SSH and rsync
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache openssh-client sshpass rsync
|
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
|
- name: Deploy built files to shared hosting
|
||||||
run: |
|
run: |
|
||||||
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
|
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
|
||||||
|
|||||||
+2
-2
@@ -19,8 +19,8 @@ RUN npm install --no-audit --no-fund
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_API_ORIGIN=
|
ARG VITE_API_URL=
|
||||||
ENV VITE_API_ORIGIN=$VITE_API_ORIGIN
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_ORIGIN: ${VITE_API_ORIGIN:-}
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
|
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
|
||||||
ports:
|
ports:
|
||||||
- '8080:80'
|
- '8080:80'
|
||||||
|
|||||||
@@ -19,5 +19,17 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
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;
|
color: #15654f;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.school-year-selector {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-year-selector .form-select {
|
||||||
|
min-width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-year-selector--compact .form-label {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-year-selector--compact .form-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-dark .school-year-selector,
|
||||||
|
.custom-navbar .school-year-selector {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-dark .school-year-selector .form-label,
|
||||||
|
.custom-navbar .school-year-selector .form-label {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|||||||
+55
-1
@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { AuthProvider } from './auth/AuthProvider'
|
import { AuthProvider } from './auth/AuthProvider'
|
||||||
import { RequireAuth } from './auth/RequireAuth'
|
import { RequireAuth } from './auth/RequireAuth'
|
||||||
import { GlobalTableSorting } from './components/GlobalTableSorting'
|
import { GlobalTableSorting } from './components/GlobalTableSorting'
|
||||||
|
import { SchoolYearProvider } from './context/SchoolYearContext'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
const PublicLayout = lazy(() =>
|
const PublicLayout = lazy(() =>
|
||||||
@@ -82,6 +83,21 @@ const SchoolYearsManagementPage = lazy(() =>
|
|||||||
default: m.SchoolYearsManagementPage,
|
default: m.SchoolYearsManagementPage,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
const SchoolYearDetailPage = lazy(() =>
|
||||||
|
import('./pages/administrator/SchoolYearDetailPage').then((m) => ({
|
||||||
|
default: m.SchoolYearDetailPage,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const SchoolYearClosingReportPage = lazy(() =>
|
||||||
|
import('./pages/administrator/SchoolYearReportsPage').then((m) => ({
|
||||||
|
default: () => <m.SchoolYearReportsPage reportType="closing" />,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const SchoolYearBalanceTransfersReportPage = lazy(() =>
|
||||||
|
import('./pages/administrator/SchoolYearReportsPage').then((m) => ({
|
||||||
|
default: () => <m.SchoolYearReportsPage reportType="balance-transfers" />,
|
||||||
|
})),
|
||||||
|
)
|
||||||
const NavBuilderPage = lazy(() =>
|
const NavBuilderPage = lazy(() =>
|
||||||
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
|
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
|
||||||
)
|
)
|
||||||
@@ -479,6 +495,20 @@ const InventoryMovementFormPage = lazy(() =>
|
|||||||
default: m.InventoryMovementFormPage,
|
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(() =>
|
const TeacherBookDistributePage = lazy(() =>
|
||||||
import('./pages/inventory/TeacherBookDistributePage').then((m) => ({
|
import('./pages/inventory/TeacherBookDistributePage').then((m) => ({
|
||||||
default: m.TeacherBookDistributePage,
|
default: m.TeacherBookDistributePage,
|
||||||
@@ -702,7 +732,7 @@ const FamilyComposeEmailPage = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const FamilyLegacyImportPage = lazy(() =>
|
const FamilyLegacyImportPage = lazy(() =>
|
||||||
import('./pages/family/FamilyLegacyImportPage').then((m) => ({ default: m.FamilyLegacyImportPage })),
|
import('./pages/FamiliesImportLegacyPage').then((m) => ({ default: m.FamiliesImportLegacyPage })),
|
||||||
)
|
)
|
||||||
const EmailTemplatesIndexPage = lazy(() =>
|
const EmailTemplatesIndexPage = lazy(() =>
|
||||||
import('./pages/emails').then((m) => ({ default: m.EmailTemplatesIndexPage })),
|
import('./pages/emails').then((m) => ({ default: m.EmailTemplatesIndexPage })),
|
||||||
@@ -992,6 +1022,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<SchoolYearProvider>
|
||||||
<GlobalTableSorting />
|
<GlobalTableSorting />
|
||||||
<Suspense fallback={<RouteFallback />}>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -1132,6 +1163,17 @@ export default function App() {
|
|||||||
<Route path="administrator/configuration_view" element={<ConfigurationViewPage />} />
|
<Route path="administrator/configuration_view" element={<ConfigurationViewPage />} />
|
||||||
<Route path="administrator/configuration" element={<Navigate to="configuration_view" replace />} />
|
<Route path="administrator/configuration" element={<Navigate to="configuration_view" replace />} />
|
||||||
<Route path="administrator/school-years" element={<SchoolYearsManagementPage />} />
|
<Route path="administrator/school-years" element={<SchoolYearsManagementPage />} />
|
||||||
|
<Route path="admin/school-years" element={<SchoolYearsManagementPage />} />
|
||||||
|
<Route path="admin/school-years/close" element={<SchoolYearsManagementPage />} />
|
||||||
|
<Route path="admin/school-years/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" element={<DiscountListPage />} />
|
||||||
<Route path="administrator/discounts/create" element={<DiscountCreatePage />} />
|
<Route path="administrator/discounts/create" element={<DiscountCreatePage />} />
|
||||||
<Route path="administrator/discounts/apply" element={<DiscountApplyVoucherPage />} />
|
<Route path="administrator/discounts/apply" element={<DiscountApplyVoucherPage />} />
|
||||||
@@ -1204,6 +1246,16 @@ export default function App() {
|
|||||||
<Route path="grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
|
<Route path="grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
|
||||||
<Route path="grading/main" element={<GradingMainPage />} />
|
<Route path="grading/main" element={<GradingMainPage />} />
|
||||||
<Route path="grading" 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" element={<InventoryBookIndexPage />} />
|
||||||
<Route path="administrator/inventory/book/create" element={<InventoryBookFormPage />} />
|
<Route path="administrator/inventory/book/create" element={<InventoryBookFormPage />} />
|
||||||
<Route path="administrator/inventory/book/:itemId/edit" 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="notifications/active" element={<NotificationsActivePage />} />
|
||||||
<Route path="administrator/notifications/deleted" element={<NotificationsDeletedPage />} />
|
<Route path="administrator/notifications/deleted" element={<NotificationsDeletedPage />} />
|
||||||
<Route path="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-pay" element={<ManualPayPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="administrator/payment/manual-payment"
|
path="administrator/payment/manual-payment"
|
||||||
@@ -1513,6 +1566,7 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</SchoolYearProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
import type { ApiEnvelope } from './types'
|
import type { ApiEnvelope } from './types'
|
||||||
|
|
||||||
export type CertificateDecisionRow = {
|
export type CertificateDecisionRow = {
|
||||||
@@ -74,6 +74,7 @@ function authHeaders(accept: string) {
|
|||||||
const headers = new Headers({ Accept: accept })
|
const headers = new Headers({ Accept: accept })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -216,7 +216,7 @@ export async function fetchBelowSixtyEmailEditor(searchParams: URLSearchParams):
|
|||||||
subject?: string
|
subject?: string
|
||||||
html?: 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> {
|
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> {
|
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(
|
export async function postPlacementBatch(
|
||||||
batchId: string | number,
|
batchId: string | number,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/placement-batch/${batchId}`, {
|
return apiFetch(`${BASE}/placement/batch/${batchId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -295,11 +295,11 @@ export async function postPlacementBatch(
|
|||||||
export async function fetchScheduleMeetingForm(
|
export async function fetchScheduleMeetingForm(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): 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> {
|
export async function postScheduleMeeting(body: Record<string, unknown>): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/schedule-meeting`, {
|
return apiFetch(`${BASE}/below-sixty/meeting`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
+20
-2
@@ -1,4 +1,5 @@
|
|||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
import { selectedSchoolYearHeaders, withSelectedSchoolYearUrl } from '../lib/schoolYearSelection'
|
||||||
|
|
||||||
const TOKEN_KEY = 'alrahma_api_token'
|
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 {
|
export class ApiHttpError extends Error {
|
||||||
readonly status: number
|
readonly status: number
|
||||||
readonly body: unknown
|
readonly body: unknown
|
||||||
@@ -60,7 +73,12 @@ export async function apiFetch<T>(
|
|||||||
headers.set('Authorization', `Bearer ${token}`)
|
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
|
let res: Response
|
||||||
|
|
||||||
@@ -71,7 +89,7 @@ export async function apiFetch<T>(
|
|||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const hint = import.meta.env.DEV
|
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'
|
const reason = e instanceof Error ? e.message : 'Failed to fetch'
|
||||||
|
|||||||
+24
-15
@@ -11,6 +11,13 @@ function q(searchParams: URLSearchParams): string {
|
|||||||
return s ? `?${s}` : ''
|
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'
|
export type InventoryKind = 'book' | 'classroom' | 'kitchen' | 'office'
|
||||||
|
|
||||||
/** Index list: expect `{ items, categories, userNames }` (names optional). */
|
/** Index list: expect `{ items, categories, userNames }` (names optional). */
|
||||||
@@ -20,11 +27,11 @@ export async function fetchInventoryIndex(
|
|||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const params = new URLSearchParams(searchParams)
|
const params = new URLSearchParams(searchParams)
|
||||||
params.set('type', kind)
|
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> {
|
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/category`, {
|
return apiFetch(`${BASE}/categories`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -37,7 +44,7 @@ export async function fetchInventoryItem(itemId: string | number): Promise<{
|
|||||||
conditionOptions?: Record<string, string>
|
conditionOptions?: Record<string, string>
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}> {
|
}> {
|
||||||
return apiFetch(`${BASE}/items/${itemId}`)
|
return unwrapData(await apiFetch(`${BASE}/items/${itemId}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create form payload (categories, defaults). */
|
/** Create form payload (categories, defaults). */
|
||||||
@@ -45,7 +52,9 @@ export async function fetchInventoryCreateForm(
|
|||||||
kind: InventoryKind,
|
kind: InventoryKind,
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): 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> {
|
export async function postInventoryItem(body: Record<string, unknown>): Promise<unknown> {
|
||||||
@@ -61,7 +70,7 @@ export async function putInventoryItem(
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/items/${itemId}`, {
|
return apiFetch(`${BASE}/items/${itemId}`, {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
@@ -86,7 +95,7 @@ export async function postClassroomAudit(
|
|||||||
itemId: string | number,
|
itemId: string | number,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/items/${itemId}/classroom-audit`, {
|
return apiFetch(`${BASE}/items/${itemId}/audit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -98,11 +107,11 @@ export async function fetchInventorySummary(searchParams: URLSearchParams): Prom
|
|||||||
const params = new URLSearchParams(searchParams)
|
const params = new URLSearchParams(searchParams)
|
||||||
params.delete('type')
|
params.delete('type')
|
||||||
const qs = params.toString()
|
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> {
|
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(
|
export async function fetchMovementForm(
|
||||||
@@ -110,9 +119,9 @@ export async function fetchMovementForm(
|
|||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
if (movementId === undefined || movementId === '') {
|
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> {
|
export async function postMovement(body: Record<string, unknown>): Promise<unknown> {
|
||||||
@@ -128,7 +137,7 @@ export async function putMovement(
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/movements/${movementId}`, {
|
return apiFetch(`${BASE}/movements/${movementId}`, {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
@@ -167,26 +176,26 @@ export async function postTeacherBookDistribute(body: Record<string, unknown>):
|
|||||||
|
|
||||||
/** Fetch low-stock items (quantity <= reorder_point). */
|
/** Fetch low-stock items (quantity <= reorder_point). */
|
||||||
export async function fetchLowStock(): Promise<unknown> {
|
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. */
|
/** Fetch reorder suggestions for low-stock items. */
|
||||||
export async function fetchReorderSuggestions(): Promise<unknown> {
|
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). */
|
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
|
||||||
export async function fetchStockStatus(
|
export async function fetchStockStatus(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/stock-status${q(searchParams)}`)
|
return unwrapData(await apiFetch(`${BASE}/stock-status${q(searchParams)}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch inventory dashboard summary counts. */
|
/** Fetch inventory dashboard summary counts. */
|
||||||
export async function fetchInventoryDashboard(
|
export async function fetchInventoryDashboard(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): 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). */
|
/** Void a movement (append-only correction with reverse entry). */
|
||||||
|
|||||||
+6
-7
@@ -2,7 +2,7 @@
|
|||||||
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
|
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { apiFetch, getStoredToken } from './http'
|
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
const BASE = '/api/v1/finance/invoices'
|
const BASE = '/api/v1/finance/invoices'
|
||||||
|
|
||||||
@@ -50,12 +50,11 @@ export async function fetchInvoicePreview(invoiceId: string | number): Promise<u
|
|||||||
/** Binary PDF with JWT (for tabs that cannot send Authorization). */
|
/** Binary PDF with JWT (for tabs that cannot send Authorization). */
|
||||||
export async function fetchInvoicePdfBlob(invoiceId: string | number): Promise<Blob> {
|
export async function fetchInvoicePdfBlob(invoiceId: string | number): Promise<Blob> {
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), {
|
const headers = new Headers({ Accept: 'application/pdf' })
|
||||||
headers: {
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
applyStoredSchoolYearHeaders(headers)
|
||||||
Accept: 'application/pdf',
|
|
||||||
},
|
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), { headers })
|
||||||
})
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error(text || `PDF HTTP ${res.status}`)
|
throw new Error(text || `PDF HTTP ${res.status}`)
|
||||||
|
|||||||
@@ -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.
|
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
export type AdminNotificationRow = {
|
export type AdminNotificationRow = {
|
||||||
id?: number
|
id?: number
|
||||||
@@ -56,6 +56,7 @@ async function postMultipart<T>(path: string, formData: FormData): Promise<T> {
|
|||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -136,6 +137,7 @@ export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string
|
|||||||
const headers = new Headers({ Accept: '*/*' })
|
const headers = new Headers({ Accept: '*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(apiPath), { headers })
|
const res = await fetch(apiUrl(apiPath), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -335,6 +337,7 @@ export async function downloadFinancialReportCsv(params: {
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -408,6 +411,7 @@ export async function downloadFinancialSummaryPdf(params: { school_year?: string
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const raw = await res.blob()
|
const raw = await res.blob()
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
* Teacher / admin print copy requests (legacy CI: print-requests/*).
|
* Teacher / admin print copy requests (legacy CI: print-requests/*).
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
export type PrintRequestRow = Record<string, unknown>
|
export type PrintRequestRow = Record<string, unknown>
|
||||||
|
|
||||||
|
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 = {
|
export type TeacherPrintRequestsContext = {
|
||||||
sundays?: string[]
|
sundays?: string[]
|
||||||
times?: 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 headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { method, headers, body: formData })
|
const res = await fetch(apiUrl(path), { method, headers, body: formData })
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -60,15 +68,15 @@ export async function deletePrintRequest(requestId: number | string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminPrintRequests(): Promise<{ print_requests: PrintRequestRow[] }> {
|
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(
|
export async function adminUpdatePrintRequestStatus(
|
||||||
requestId: number | string,
|
requestId: number | string,
|
||||||
payload: { status: string; admin_id?: number | string },
|
payload: { status: string; admin_id?: number | string },
|
||||||
): Promise<{ ok?: boolean; message?: string }> {
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
return apiFetch(`/api/v1/administrator/print-requests/${encodeURIComponent(String(requestId))}/status`, {
|
return apiFetch(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}/status`, {
|
||||||
method: 'POST',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
@@ -83,6 +91,7 @@ export async function openPrintRequestFile(requestId: number | string): Promise<
|
|||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
const headers = new Headers({ Accept: '*/*' })
|
const headers = new Headers({ Accept: '*/*' })
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(printRequestFileUrl(requestId)), { headers })
|
const res = await fetch(apiUrl(printRequestFileUrl(requestId)), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
|
|||||||
+103
-23
@@ -2,7 +2,7 @@
|
|||||||
* Stickers, badges, report cards (legacy CI printables_reports/*).
|
* Stickers, badges, report cards (legacy CI printables_reports/*).
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
export type ClassOption = { class_section_id?: number; id?: number; class_section_name?: string }
|
export type ClassOption = { class_section_id?: number; id?: number; class_section_name?: string }
|
||||||
|
|
||||||
@@ -25,22 +25,31 @@ export async function fetchStickerFormOptions(params?: {
|
|||||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
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[]> {
|
export async function fetchStudentsByClass(classId: number | string): Promise<StudentOption[]> {
|
||||||
const body = await apiFetch<{ students?: StudentOption[] } | StudentOption[]>(
|
const qs = new URLSearchParams({ class_section_id: String(classId) })
|
||||||
`/api/v1/administrator/students/by-class/${encodeURIComponent(String(classId))}`,
|
const body = await apiFetch<{
|
||||||
)
|
ok?: boolean
|
||||||
if (Array.isArray(body)) return body
|
data?: { students?: StudentOption[] }
|
||||||
return body.students ?? []
|
students?: StudentOption[]
|
||||||
|
}>(`/api/v1/reports/stickers/students?${qs}`)
|
||||||
|
return body.data?.students ?? body.students ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postStickerGenerate(formData: FormData): Promise<Blob> {
|
export async function postStickerGenerate(formData: FormData): Promise<Blob> {
|
||||||
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
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',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -61,6 +70,7 @@ export async function postBadgeGenerate(formData: FormData): Promise<Blob> {
|
|||||||
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl('/api/v1/badges/pdf'), {
|
const res = await fetch(apiUrl('/api/v1/badges/pdf'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -89,7 +99,7 @@ export async function fetchStickerPreviewCounts(classId?: number | string | null
|
|||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
const body = await apiFetch<
|
const body = await apiFetch<
|
||||||
StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts }
|
StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts }
|
||||||
>(`/api/v1/administrator/printables/stickers/preview${suffix}`)
|
>(`/api/v1/reports/stickers/preview${suffix}`)
|
||||||
if (body.data) return body.data
|
if (body.data) return body.data
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
@@ -170,7 +180,8 @@ export async function fetchReportCardMeta(params?: {
|
|||||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
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: {
|
export async function fetchReportCardAck(params: {
|
||||||
@@ -182,9 +193,11 @@ export async function fetchReportCardAck(params: {
|
|||||||
qs.set('student_id', String(params.student_id))
|
qs.set('student_id', String(params.student_id))
|
||||||
if (params.school_year) qs.set('school_year', params.school_year)
|
if (params.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params.semester) qs.set('semester', params.semester ?? '')
|
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: {
|
export async function fetchReportCardCompleteness(params: {
|
||||||
class_section_id: number | string
|
class_section_id: number | string
|
||||||
school_year?: string
|
school_year?: string
|
||||||
@@ -199,11 +212,17 @@ export async function fetchReportCardCompleteness(params: {
|
|||||||
qs.set('class_section_id', String(params.class_section_id))
|
qs.set('class_section_id', String(params.class_section_id))
|
||||||
if (params.school_year) qs.set('school_year', params.school_year ?? '')
|
if (params.school_year) qs.set('school_year', params.school_year ?? '')
|
||||||
if (params.semester) qs.set('semester', params.semester ?? '')
|
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,
|
studentId: number | string,
|
||||||
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
||||||
): string {
|
): string {
|
||||||
@@ -213,10 +232,10 @@ export function reportCardStudentUrl(
|
|||||||
if (opts.download) qs.set('download', '1')
|
if (opts.download) qs.set('download', '1')
|
||||||
if (opts.report_date) qs.set('report_date', opts.report_date)
|
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||||
qs.set('cb', String(Date.now()))
|
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,
|
classSectionId: number | string,
|
||||||
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
||||||
): string {
|
): string {
|
||||||
@@ -226,7 +245,22 @@ export function reportCardClassUrl(
|
|||||||
if (opts.download) qs.set('download', '1')
|
if (opts.download) qs.set('download', '1')
|
||||||
if (opts.report_date) qs.set('report_date', opts.report_date)
|
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||||
qs.set('cb', String(Date.now()))
|
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). */
|
/** 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 headers = new Headers({ Accept: 'text/html' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const path = `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
|
const path = `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
@@ -260,20 +295,65 @@ export async function fetchReportCardClassHtml(
|
|||||||
const headers = new Headers({ Accept: 'text/html' })
|
const headers = new Headers({ Accept: 'text/html' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const path = `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
|
const path = `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
return res.text()
|
return res.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens PDF download in new tab (authenticated GET). */
|
async function fetchReportCardPdf(path: string): Promise<Blob> {
|
||||||
export async function openReportCardDownload(url: string): Promise<void> {
|
|
||||||
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
const headers = new Headers({ Accept: 'application/pdf,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
|
if (!token) {
|
||||||
|
throw new ApiHttpError('Your session has expired. Please sign in again.', 401, null)
|
||||||
|
}
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
const res = await fetch(url, { headers })
|
applyStoredSchoolYearHeaders(headers)
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
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 raw = await res.blob()
|
||||||
const blob = new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
|
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
|
||||||
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
|
}
|
||||||
|
|
||||||
|
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`.
|
* Backend is expected under `/api/v1/administrator/reimbursements`.
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
|
|
||||||
const BASE = '/api/v1/administrator/reimbursements'
|
const BASE = '/api/v1/administrator/reimbursements'
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ export async function downloadReimbursementsExport(params?: {
|
|||||||
const headers = new Headers({ Accept: 'text/csv,*/*' })
|
const headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(url, { headers })
|
const res = await fetch(url, { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -66,10 +67,11 @@ export async function downloadReimbursementsExport(params?: {
|
|||||||
|
|
||||||
export async function downloadBatchCsv(batchId: number | string): Promise<void> {
|
export async function downloadBatchCsv(batchId: number | string): Promise<void> {
|
||||||
const qs = new URLSearchParams({ batch_id: String(batchId) })
|
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 headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(url, { headers })
|
const res = await fetch(url, { headers })
|
||||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
@@ -80,7 +82,8 @@ export async function postBatchEmail(formData: FormData): Promise<{ success?: bo
|
|||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
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)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg =
|
const msg =
|
||||||
@@ -111,13 +114,14 @@ export async function createReimbursementBatch(): Promise<{
|
|||||||
csrf_hash?: string
|
csrf_hash?: string
|
||||||
}> {
|
}> {
|
||||||
const fd = new FormData()
|
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>> {
|
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 headers = new Headers({ Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: form })
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: form })
|
||||||
const body: unknown = await res.json().catch(() => ({}))
|
const body: unknown = await res.json().catch(() => ({}))
|
||||||
const data = body as Record<string, unknown>
|
const data = body as Record<string, unknown>
|
||||||
@@ -131,15 +135,15 @@ async function postFormExpectJson(path: string, form: FormData): Promise<{ succe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function postBatchUpdate(form: FormData) {
|
export function postBatchUpdate(form: FormData) {
|
||||||
return postFormExpectJson(`${BASE}/batch/update`, form)
|
return postFormExpectJson(`${BASE}/batches/assign`, form)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postBatchLock(form: FormData) {
|
export function postBatchLock(form: FormData) {
|
||||||
return postFormExpectJson(`${BASE}/batch/lock`, form)
|
return postFormExpectJson(`${BASE}/batches/lock`, form)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postAdminCheckUpload(form: FormData) {
|
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) {
|
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 headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
+190
-25
@@ -9,6 +9,7 @@ export type SchoolYearRecord = {
|
|||||||
end_date: string
|
end_date: string
|
||||||
status: SchoolYearStatus
|
status: SchoolYearStatus
|
||||||
is_current: boolean
|
is_current: boolean
|
||||||
|
isCurrent?: boolean
|
||||||
closed_at?: string | null
|
closed_at?: string | null
|
||||||
closed_by?: number | null
|
closed_by?: number | null
|
||||||
}
|
}
|
||||||
@@ -19,7 +20,11 @@ export type SchoolYearSummary = {
|
|||||||
students_considered: number
|
students_considered: number
|
||||||
students_blocked: number
|
students_blocked: number
|
||||||
parents_with_balances: number
|
parents_with_balances: number
|
||||||
|
parents_with_positive_balance?: number
|
||||||
net_balance_to_transfer: 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
|
parent_name?: string | null
|
||||||
student_names?: string[]
|
student_names?: string[]
|
||||||
old_unpaid_balance: number
|
old_unpaid_balance: number
|
||||||
|
positive_unpaid_balance?: number
|
||||||
credit_balance: number
|
credit_balance: number
|
||||||
|
net_balance?: number
|
||||||
amount_to_transfer: number
|
amount_to_transfer: number
|
||||||
|
positive_invoice_ids?: number[]
|
||||||
|
credit_invoice_ids?: number[]
|
||||||
|
source_invoice_ids?: number[]
|
||||||
existing_transfer_conflict?: boolean
|
existing_transfer_conflict?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SchoolYearParentBalancePreview = {
|
export type SchoolYearParentBalancePreview = {
|
||||||
|
from_school_year?: string
|
||||||
|
to_school_year?: string
|
||||||
rows: SchoolYearParentBalanceRow[]
|
rows: SchoolYearParentBalanceRow[]
|
||||||
summary: {
|
summary: {
|
||||||
parents_with_non_zero_balance: number
|
parents_with_non_zero_balance: number
|
||||||
|
parents_with_positive_balance?: number
|
||||||
parents_with_credit_balance: number
|
parents_with_credit_balance: number
|
||||||
total_old_unpaid_balance: number
|
total_old_unpaid_balance: number
|
||||||
|
total_positive_unpaid_balance?: number
|
||||||
total_credit_balance: number
|
total_credit_balance: number
|
||||||
net_balance_to_transfer: number
|
net_balance_to_transfer: number
|
||||||
|
net_balance_impact?: number
|
||||||
existing_transfer_conflicts: number
|
existing_transfer_conflicts: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,6 +110,67 @@ export type SchoolYearCloseResult = {
|
|||||||
balance_counts?: Record<string, number>
|
balance_counts?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SchoolYearClosingReport = {
|
||||||
|
old_school_year?: SchoolYearRecord | null
|
||||||
|
new_school_year?: SchoolYearRecord | null
|
||||||
|
closed_by?: string | number | null
|
||||||
|
closed_at?: string | null
|
||||||
|
audit_reference?: string | null
|
||||||
|
totals?: {
|
||||||
|
students_promoted?: number
|
||||||
|
students_repeated?: number
|
||||||
|
students_graduated?: number
|
||||||
|
students_transferred?: number
|
||||||
|
students_withdrawn?: number
|
||||||
|
parents_with_unpaid_balances?: number
|
||||||
|
total_unpaid_balance_transferred?: number
|
||||||
|
parents_with_credit_balances?: number
|
||||||
|
total_credit_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 = {
|
export type SaveSchoolYearPayload = {
|
||||||
name: string
|
name: string
|
||||||
start_date: string
|
start_date: string
|
||||||
@@ -103,6 +179,15 @@ export type SaveSchoolYearPayload = {
|
|||||||
|
|
||||||
export type CloseSchoolYearPayload = {
|
export type CloseSchoolYearPayload = {
|
||||||
new_school_year: SaveSchoolYearPayload
|
new_school_year: SaveSchoolYearPayload
|
||||||
|
promotion_rules?: {
|
||||||
|
default_action?: string
|
||||||
|
exceptions?: Array<{
|
||||||
|
student_id: number
|
||||||
|
action: string
|
||||||
|
target_grade_id?: number | null
|
||||||
|
target_class_section_id?: number | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
transfer_unpaid_balances?: boolean
|
transfer_unpaid_balances?: boolean
|
||||||
confirmation?: string
|
confirmation?: string
|
||||||
}
|
}
|
||||||
@@ -147,6 +232,21 @@ type ApiCloseResponse = {
|
|||||||
data?: SchoolYearCloseResult
|
data?: SchoolYearCloseResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiClosingReportResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
data?: SchoolYearClosingReport
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiBalanceTransferReportResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
data?: SchoolYearBalanceTransferReport
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiParentStatementResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
data?: ParentStatement
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSchoolYears(): Promise<SchoolYearRecord[]> {
|
export async function fetchSchoolYears(): Promise<SchoolYearRecord[]> {
|
||||||
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
|
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
|
||||||
return response.data ?? []
|
return response.data ?? []
|
||||||
@@ -167,14 +267,17 @@ export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSchoolYear(
|
export async function updateSchoolYear(
|
||||||
schoolYearId: number,
|
schoolYear: string,
|
||||||
payload: SaveSchoolYearPayload,
|
payload: SaveSchoolYearPayload,
|
||||||
): Promise<SchoolYearRecord> {
|
): Promise<SchoolYearRecord> {
|
||||||
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}`, {
|
const response = await apiFetch<ApiRecordResponse>(
|
||||||
method: 'PATCH',
|
`/api/v1/school-years/selected${selectedYearQuery(schoolYear)}`,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify(payload),
|
method: 'PATCH',
|
||||||
})
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: selectedYearBody(payload, schoolYear),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
throw new Error('School year update returned no record.')
|
throw new Error('School year update returned no record.')
|
||||||
@@ -183,8 +286,23 @@ export async function updateSchoolYear(
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSchoolYearSummary(schoolYearId: number): Promise<SchoolYearSummary> {
|
function selectedYearQuery(schoolYear: string, extras?: Record<string, string | undefined>): string {
|
||||||
const response = await apiFetch<ApiSummaryResponse>(`/api/v1/school-years/${schoolYearId}/summary`)
|
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) {
|
if (!response.data) {
|
||||||
throw new Error('School year summary returned no 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(
|
export async function validateSchoolYearClose(
|
||||||
schoolYearId: number,
|
schoolYear: string,
|
||||||
payload: CloseSchoolYearPayload,
|
payload: CloseSchoolYearPayload,
|
||||||
): Promise<SchoolYearCloseValidation> {
|
): Promise<SchoolYearCloseValidation> {
|
||||||
const response = await apiFetch<ApiValidationResponse>(
|
const response = await apiFetch<ApiValidationResponse>(
|
||||||
`/api/v1/school-years/${schoolYearId}/validate-close`,
|
`/api/v1/school-years/validate-close${selectedYearQuery(schoolYear)}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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(
|
export async function previewSchoolYearClose(
|
||||||
schoolYearId: number,
|
schoolYear: string,
|
||||||
payload: CloseSchoolYearPayload,
|
payload: CloseSchoolYearPayload,
|
||||||
): Promise<SchoolYearClosePreview> {
|
): Promise<SchoolYearClosePreview> {
|
||||||
const response = await apiFetch<ApiPreviewResponse>(
|
const response = await apiFetch<ApiPreviewResponse>(
|
||||||
`/api/v1/school-years/${schoolYearId}/preview-close`,
|
`/api/v1/school-years/preview-close${selectedYearQuery(schoolYear)}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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(
|
export async function closeSchoolYear(
|
||||||
schoolYearId: number,
|
schoolYear: string,
|
||||||
payload: CloseSchoolYearPayload,
|
payload: CloseSchoolYearPayload,
|
||||||
): Promise<SchoolYearCloseResult> {
|
): 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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: selectedYearBody(payload, schoolYear),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
@@ -248,10 +366,11 @@ export async function closeSchoolYear(
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reopenSchoolYear(schoolYearId: number): Promise<SchoolYearRecord> {
|
export async function reopenSchoolYear(schoolYear: string): Promise<SchoolYearRecord> {
|
||||||
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}/reopen`, {
|
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/reopen${selectedYearQuery(schoolYear)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ school_year: schoolYear }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
@@ -262,12 +381,11 @@ export async function reopenSchoolYear(schoolYearId: number): Promise<SchoolYear
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSchoolYearParentBalances(
|
export async function fetchSchoolYearParentBalances(
|
||||||
schoolYearId: number,
|
schoolYear: string,
|
||||||
toSchoolYear?: string,
|
toSchoolYear?: string,
|
||||||
): Promise<SchoolYearParentBalancePreview> {
|
): Promise<SchoolYearParentBalancePreview> {
|
||||||
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
|
|
||||||
const response = await apiFetch<ApiParentBalancesResponse>(
|
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) {
|
if (!response.data) {
|
||||||
@@ -278,12 +396,11 @@ export async function fetchSchoolYearParentBalances(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSchoolYearPromotionPreview(
|
export async function fetchSchoolYearPromotionPreview(
|
||||||
schoolYearId: number,
|
schoolYear: string,
|
||||||
toSchoolYear?: string,
|
toSchoolYear?: string,
|
||||||
): Promise<SchoolYearPromotionPreview> {
|
): Promise<SchoolYearPromotionPreview> {
|
||||||
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
|
|
||||||
const response = await apiFetch<ApiPromotionPreviewResponse>(
|
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) {
|
if (!response.data) {
|
||||||
@@ -292,3 +409,51 @@ export async function fetchSchoolYearPromotionPreview(
|
|||||||
|
|
||||||
return response.data
|
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 { apiUrl } from '../lib/apiOrigin'
|
||||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
import type {
|
import type {
|
||||||
AdministratorAbsenceFormResponse,
|
AdministratorAbsenceFormResponse,
|
||||||
AdministratorAbsenceSubmitResponse,
|
AdministratorAbsenceSubmitResponse,
|
||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
NavMenuResponse,
|
NavMenuResponse,
|
||||||
ParentAttendanceResponse,
|
ParentAttendanceResponse,
|
||||||
ParentAttendanceReportFormResponse,
|
ParentAttendanceReportFormResponse,
|
||||||
|
ParentEnrollmentActionResponse,
|
||||||
ParentEnrollmentsResponse,
|
ParentEnrollmentsResponse,
|
||||||
ParentEventsOverviewResponse,
|
ParentEventsOverviewResponse,
|
||||||
ParentInvoicesResponse,
|
ParentInvoicesResponse,
|
||||||
@@ -595,6 +596,7 @@ export async function uploadEarlyDismissalSignature(formData: FormData): Promise
|
|||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl('/api/v1/attendance/early-dismissals/signature'), {
|
const res = await fetch(apiUrl('/api/v1/attendance/early-dismissals/signature'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -1224,6 +1226,7 @@ export async function openProtectedApiFile(path: string): Promise<void> {
|
|||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
|
|
||||||
const res = await fetch(apiUrl(path), { headers })
|
const res = await fetch(apiUrl(path), { headers })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -1255,6 +1258,7 @@ async function fetchMultipartJson<T>(
|
|||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(path), {
|
const res = await fetch(apiUrl(path), {
|
||||||
method: init.method ?? 'POST',
|
method: init.method ?? 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -1718,10 +1722,15 @@ export async function sendFamilyComposeEmail(payload: {
|
|||||||
html: string
|
html: string
|
||||||
return_url?: string
|
return_url?: string
|
||||||
}): Promise<{ ok?: boolean; message?: 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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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?.q) query.set('q', params.q)
|
||||||
if (params?.perPage) query.set('per_page', String(params.perPage))
|
if (params?.perPage) query.set('per_page', String(params.perPage))
|
||||||
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
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> {
|
export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promise<Blob> {
|
||||||
const headers = new Headers({ Accept: 'text/csv' })
|
const headers = new Headers({ Accept: 'text/csv' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (params?.q) query.set('q', params.q)
|
if (params?.q) query.set('q', params.q)
|
||||||
const suffix = query.size > 0 ? `?${query.toString()}` : ''
|
const suffix = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
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}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.blob()
|
return res.blob()
|
||||||
}
|
}
|
||||||
@@ -1854,6 +1864,17 @@ export async function fetchParentEnrollments(
|
|||||||
return apiFetch<ParentEnrollmentsResponse>(`/api/v1/parents/enrollments${q}`)
|
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(
|
export async function fetchParentInvoices(
|
||||||
schoolYear: string | null,
|
schoolYear: string | null,
|
||||||
): Promise<ParentInvoicesResponse> {
|
): Promise<ParentInvoicesResponse> {
|
||||||
@@ -1953,6 +1974,8 @@ export async function fetchClassProgressGroups(params?: {
|
|||||||
weekStart?: string | null
|
weekStart?: string | null
|
||||||
weekEnd?: string | null
|
weekEnd?: string | null
|
||||||
subject?: string | null
|
subject?: string | null
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
page?: number
|
page?: number
|
||||||
perPage?: number
|
perPage?: number
|
||||||
}): Promise<ClassProgressGroupsResponse> {
|
}): Promise<ClassProgressGroupsResponse> {
|
||||||
@@ -1963,6 +1986,8 @@ export async function fetchClassProgressGroups(params?: {
|
|||||||
if (params?.weekStart) query.set('week_start', params.weekStart)
|
if (params?.weekStart) query.set('week_start', params.weekStart)
|
||||||
if (params?.weekEnd) query.set('week_end', params.weekEnd)
|
if (params?.weekEnd) query.set('week_end', params.weekEnd)
|
||||||
if (params?.subject) query.set('subject', params.subject)
|
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?.page) query.set('page', String(params.page))
|
||||||
if (params?.perPage) query.set('per_page', String(params.perPage))
|
if (params?.perPage) query.set('per_page', String(params.perPage))
|
||||||
return apiFetch<ClassProgressGroupsResponse>(`/api/v1/class-progress?${query.toString()}`)
|
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?: {
|
export async function fetchClassProgressMeta(params?: {
|
||||||
classId?: number | null
|
classId?: number | null
|
||||||
sundayCount?: number | null
|
sundayCount?: number | null
|
||||||
|
schoolYear?: string | null
|
||||||
|
semester?: string | null
|
||||||
}): Promise<ClassProgressMetaResponse> {
|
}): Promise<ClassProgressMetaResponse> {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (params?.classId) query.set('class_id', String(params.classId))
|
if (params?.classId) query.set('class_id', String(params.classId))
|
||||||
if (params?.sundayCount) query.set('sunday_count', String(params.sundayCount))
|
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()}` : ''
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
return apiFetch<ClassProgressMetaResponse>(`/api/v1/class-progress/meta${qs}`)
|
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 headers = new Headers({ Accept: 'application/pdf' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
const res = await fetch(apiUrl(`/api/v1/reports/report-cards/students/${studentId}`), {
|
const res = await fetch(apiUrl(`/api/v1/reports/report-cards/students/${studentId}`), {
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
|
|||||||
+39
-11
@@ -1,10 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Staff directory (legacy CI `staff/*`).
|
* Staff directory.
|
||||||
* Expected Laravel routes under `/api/v1/administrator/staff`.
|
|
||||||
*/
|
*/
|
||||||
import { apiFetch } from './http'
|
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 = {
|
export type StaffListRow = {
|
||||||
id: number
|
id: number
|
||||||
@@ -17,6 +23,7 @@ export type StaffListRow = {
|
|||||||
active_role?: string | null
|
active_role?: string | null
|
||||||
class_section?: string | null
|
class_section?: string | null
|
||||||
verification_issue?: boolean
|
verification_issue?: boolean
|
||||||
|
status?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StaffIndexResponse = {
|
export type StaffIndexResponse = {
|
||||||
@@ -25,12 +32,16 @@ export type StaffIndexResponse = {
|
|||||||
school_year?: string | null
|
school_year?: string | null
|
||||||
semester?: string | null
|
semester?: string | null
|
||||||
schoolYears?: string[]
|
schoolYears?: string[]
|
||||||
|
meta?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoleOption = { id: number; name: string }
|
export type RoleOption = { id: number; name: string; label?: string }
|
||||||
|
|
||||||
export type StaffFormOptionsResponse = {
|
export type StaffFormOptionsResponse = {
|
||||||
roles?: RoleOption[]
|
roles?: RoleOption[]
|
||||||
|
statuses?: Array<{ value: string; label: string }>
|
||||||
|
school_year?: string | null
|
||||||
|
is_read_only_year?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StaffMemberResponse = {
|
export type StaffMemberResponse = {
|
||||||
@@ -52,28 +63,45 @@ export type StaffMemberResponse = {
|
|||||||
export async function fetchStaffIndex(params?: {
|
export async function fetchStaffIndex(params?: {
|
||||||
school_year?: string
|
school_year?: string
|
||||||
semester?: string
|
semester?: string
|
||||||
|
role?: string
|
||||||
|
status?: string
|
||||||
|
search?: string
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
}): Promise<StaffIndexResponse> {
|
}): Promise<StaffIndexResponse> {
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
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}` : ''
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
return apiFetch(`${BASE}${suffix}`)
|
return unwrapData(await apiFetch(`${BASE}${suffix}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> {
|
export async function fetchStaffFormOptions(params?: { school_year?: string }): Promise<StaffFormOptionsResponse> {
|
||||||
return apiFetch(`${BASE}/form-options`)
|
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> {
|
export async function fetchStaffMember(id: number, params?: { school_year?: string }): Promise<StaffMemberResponse> {
|
||||||
return apiFetch(`${BASE}/${id}`)
|
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: {
|
export async function createStaff(payload: {
|
||||||
firstname: string
|
firstname: string
|
||||||
lastname: string
|
lastname: string
|
||||||
email: string
|
email: string
|
||||||
|
phone?: string
|
||||||
password: string
|
password: string
|
||||||
role_id: number
|
role_id: number
|
||||||
|
status?: string
|
||||||
|
school_year: string
|
||||||
}): Promise<{ ok?: boolean; message?: string }> {
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
return apiFetch(`${BASE}`, {
|
return apiFetch(`${BASE}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -84,10 +112,10 @@ export async function createStaff(payload: {
|
|||||||
|
|
||||||
export async function updateStaff(
|
export async function updateStaff(
|
||||||
id: number,
|
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 }> {
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
return apiFetch(`${BASE}/${id}`, {
|
return apiFetch(`${BASE}/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Teacher portal API (`Views/teacher/*` parity).
|
* Teacher portal API (`Views/teacher/*` parity).
|
||||||
* Laravel should expose matching routes under `/api/v1/teacher/...`.
|
* Laravel should expose matching routes under `/api/v1/teacher/...`.
|
||||||
*/
|
*/
|
||||||
import { apiFetch, getStoredToken } from './http'
|
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||||
import { apiUrl } from '../lib/apiOrigin'
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
|
||||||
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
|
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
|
||||||
@@ -47,6 +47,7 @@ export async function postTeacherMultipart<T>(path: string, formData: FormData):
|
|||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
applyStoredSchoolYearHeaders(headers)
|
||||||
|
|
||||||
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
|
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AuthUser = {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
roles: Record<string, boolean>
|
roles: Record<string, boolean>
|
||||||
|
permissions?: Record<string, boolean> | string[]
|
||||||
class_section_id?: number | null
|
class_section_id?: number | null
|
||||||
class_section_name?: string | null
|
class_section_name?: string | null
|
||||||
}
|
}
|
||||||
@@ -348,9 +349,23 @@ export type ParentProfileResponse = {
|
|||||||
export type ParentInvoiceRow = {
|
export type ParentInvoiceRow = {
|
||||||
id: number
|
id: number
|
||||||
invoice_number?: string | null
|
invoice_number?: string | null
|
||||||
|
total_amount?: number
|
||||||
|
paid_amount?: number
|
||||||
balance?: number
|
balance?: number
|
||||||
status?: string | null
|
status?: string | null
|
||||||
due_date?: 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 = {
|
export type ParentInvoicesResponse = {
|
||||||
@@ -376,6 +391,19 @@ export type ParentEnrollmentsResponse = {
|
|||||||
students: ParentEnrollmentStudent[]
|
students: ParentEnrollmentStudent[]
|
||||||
schoolYears: { school_year: string }[]
|
schoolYears: { school_year: string }[]
|
||||||
selectedYear: string | null
|
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 = {
|
export type ContactSubmitResponse = {
|
||||||
|
|||||||
+55
-9
@@ -6,6 +6,13 @@ import { apiFetch } from './http'
|
|||||||
|
|
||||||
const BASE = '/api/v1/whatsapp'
|
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 = {
|
export type WhatsappSectionRow = {
|
||||||
class_section_id?: number
|
class_section_id?: number
|
||||||
id?: number
|
id?: number
|
||||||
@@ -16,6 +23,9 @@ export type WhatsappSectionRow = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WhatsappLinkRow = {
|
export type WhatsappLinkRow = {
|
||||||
|
id?: number
|
||||||
|
class_section_id?: number
|
||||||
|
class_section_name?: string
|
||||||
invite_link?: string | null
|
invite_link?: string | null
|
||||||
active?: number | boolean
|
active?: number | boolean
|
||||||
}
|
}
|
||||||
@@ -79,8 +89,29 @@ export async function fetchWhatsappManageLinks(params?: {
|
|||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
if (!qs.has('per_page')) qs.set('per_page', '200')
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
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: {
|
export async function saveWhatsappLink(payload: {
|
||||||
@@ -89,11 +120,11 @@ export async function saveWhatsappLink(payload: {
|
|||||||
invite_link: string
|
invite_link: string
|
||||||
active?: boolean
|
active?: boolean
|
||||||
}): Promise<{ ok?: boolean; message?: string }> {
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
return apiFetch(`${BASE}/links`, {
|
return unwrapData(await apiFetch(`${BASE}/links`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendWhatsappInvites(payload: {
|
export async function sendWhatsappInvites(payload: {
|
||||||
@@ -101,11 +132,11 @@ export async function sendWhatsappInvites(payload: {
|
|||||||
class_section_id?: number | null
|
class_section_id?: number | null
|
||||||
parent_ids?: number[]
|
parent_ids?: number[]
|
||||||
}): Promise<{ ok?: boolean; message?: string }> {
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
return apiFetch(`${BASE}/invites/send`, {
|
return unwrapData(await apiFetch(`${BASE}/invites/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWhatsappParentContacts(params?: {
|
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?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
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> {
|
export async function fetchWhatsappParentContactsByClass(params?: {
|
||||||
return apiFetch(`${BASE}/parent-contacts-by-class`)
|
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: {
|
export async function updateWhatsappMembership(payload: {
|
||||||
@@ -132,9 +177,10 @@ export async function updateWhatsappMembership(payload: {
|
|||||||
primary_member?: string
|
primary_member?: string
|
||||||
second_member?: string
|
second_member?: string
|
||||||
}): Promise<{ status?: string; message?: string }> {
|
}): Promise<{ status?: string; message?: string }> {
|
||||||
return apiFetch(`${BASE}/membership`, {
|
const body = await apiFetch<{ status?: boolean; message?: string; data?: unknown }>(`${BASE}/membership`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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 { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
import { CiPublicFooter } from '../components/CiPartials'
|
import { CiPublicFooter } from '../components/CiPartials'
|
||||||
|
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
|
||||||
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
|
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
|
||||||
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
|
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
|
||||||
|
|
||||||
@@ -169,7 +170,6 @@ export function MainLayout() {
|
|||||||
<div className="container-fluid">
|
<div className="container-fluid">
|
||||||
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
|
<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" />
|
<img className="main-layout-logo" src="/logo.png" alt="Al Rahma Sunday School logo" />
|
||||||
Al Rahma Sunday School
|
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="navbar-toggler"
|
className="navbar-toggler"
|
||||||
@@ -214,6 +214,11 @@ export function MainLayout() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
{user ? (
|
||||||
|
<div className="d-none d-lg-flex align-items-center me-2">
|
||||||
|
<SchoolYearSelector compact />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{user ? (
|
{user ? (
|
||||||
<div
|
<div
|
||||||
ref={clusterRef}
|
ref={clusterRef}
|
||||||
@@ -333,6 +338,7 @@ export function MainLayout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mt-4 flex-grow-1">
|
<main className="container mt-4 flex-grow-1">
|
||||||
|
<ReadOnlyBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiHttpError } from '../api/http'
|
|||||||
import { fetchNavMenu } from '../api/session'
|
import { fetchNavMenu } from '../api/session'
|
||||||
import type { NavItem } from '../api/types'
|
import type { NavItem } from '../api/types'
|
||||||
import { useAuth } from '../auth/AuthProvider'
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
|
||||||
import {
|
import {
|
||||||
isExternalNavUrl,
|
isExternalNavUrl,
|
||||||
spaPathFromCiUrl,
|
spaPathFromCiUrl,
|
||||||
@@ -230,6 +231,11 @@ export function ManagementLayout() {
|
|||||||
|
|
||||||
const navTree = useMemo(() => normalizeNavItems(items), [items])
|
const navTree = useMemo(() => normalizeNavItems(items), [items])
|
||||||
|
|
||||||
|
// Auto-hide sidebar after navigating to a subject
|
||||||
|
useEffect(() => {
|
||||||
|
setSidebarOpen(false)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
|
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
|
||||||
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
|
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
|
||||||
@@ -238,6 +244,7 @@ export function ManagementLayout() {
|
|||||||
Al Rahma Sunday School
|
Al Rahma Sunday School
|
||||||
</Link>
|
</Link>
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<SchoolYearSelector compact />
|
||||||
<span className="navbar-text small mb-0">{user?.name}</span>
|
<span className="navbar-text small mb-0">{user?.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -309,6 +316,7 @@ export function ManagementLayout() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="content management-main">
|
<main className="content management-main">
|
||||||
|
<ReadOnlyBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ const PROD_API_ORIGIN = 'https://api.alrahmaisgl.org'
|
|||||||
* Laravel API base URL.
|
* Laravel API base URL.
|
||||||
*
|
*
|
||||||
* Dev defaults to same-origin `/api/...` so Vite can proxy requests to Laravel.
|
* 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.
|
* Set `VITE_API_URL` when you want the browser to call Laravel directly, e.g.
|
||||||
* `VITE_API_ORIGIN=http://localhost:8000 npm run dev`.
|
* `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.
|
* `VITE_PROXY_API` or `http://localhost:8000` by default.
|
||||||
*/
|
*/
|
||||||
export function apiOrigin(): string {
|
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()) {
|
if (typeof v === 'string' && v.trim()) {
|
||||||
return v.replace(/\/$/, '')
|
return v.replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function makeOption(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function optionFromRecord(record: SchoolYearRecord): SchoolYearOption | null {
|
function optionFromRecord(record: SchoolYearRecord): SchoolYearOption | null {
|
||||||
return makeOption(record.name, record.status, Boolean(record.is_current), record.id)
|
return makeOption(record.name, record.status, Boolean(record.is_current || record.isCurrent), record.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null {
|
function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null {
|
||||||
|
|||||||
@@ -0,0 +1,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
|
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() {
|
export function AdminEmergencyContactIndexPage() {
|
||||||
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
|
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -82,7 +103,7 @@ export function AdminEmergencyContactIndexPage() {
|
|||||||
|
|
||||||
const displayed = useMemo(() => {
|
const displayed = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
let rows = q
|
const rows = q
|
||||||
? flatRows.filter((r) =>
|
? flatRows.filter((r) =>
|
||||||
r.parentName.toLowerCase().includes(q) ||
|
r.parentName.toLowerCase().includes(q) ||
|
||||||
r.students.toLowerCase().includes(q) ||
|
r.students.toLowerCase().includes(q) ||
|
||||||
@@ -97,19 +118,6 @@ export function AdminEmergencyContactIndexPage() {
|
|||||||
})
|
})
|
||||||
}, [flatRows, search, sortKey, sortDir])
|
}, [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 (
|
return (
|
||||||
<PageShell title="Emergency Contact Information">
|
<PageShell title="Emergency Contact Information">
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{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">
|
<table className="table table-bordered table-striped align-middle">
|
||||||
<thead className="table-dark">
|
<thead className="table-dark">
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="parentName" label="Parent Name" />
|
<SortTh col="parentName" label="Parent Name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||||
<SortTh col="students" label="Students" />
|
<SortTh col="students" label="Students" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||||
<SortTh col="contactName" label="Contact Name" />
|
<SortTh col="contactName" label="Contact Name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||||
<SortTh col="relation" label="Relation" />
|
<SortTh col="relation" label="Relation" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||||
<SortTh col="phone" label="Phone" />
|
<SortTh col="phone" label="Phone" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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 { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import './AdminDailyAttendance.css'
|
import './AdminDailyAttendance.css'
|
||||||
import { ApiHttpError } from '../api/http'
|
import { ApiHttpError } from '../api/http'
|
||||||
|
import { fetchParentProfile, fetchParentProfiles, updateParentProfile, type ParentProfileDetail, type ParentProfileSummary } from '../api/parentProfiles'
|
||||||
import { CiAcademicFilter } from '../components/CiPartials'
|
import { CiAcademicFilter } from '../components/CiPartials'
|
||||||
|
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
|
||||||
|
import { useSchoolYear } from '../context/SchoolYearContext'
|
||||||
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
|
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
|
||||||
import {
|
import {
|
||||||
assignTeacherClass,
|
assignTeacherClass,
|
||||||
@@ -25,7 +28,6 @@ import {
|
|||||||
fetchClassAssignmentOverview,
|
fetchClassAssignmentOverview,
|
||||||
fetchClassSections,
|
fetchClassSections,
|
||||||
fetchExamDraftAdminData,
|
fetchExamDraftAdminData,
|
||||||
fetchFamilyAdminIndex,
|
|
||||||
fetchGradingOverview,
|
fetchGradingOverview,
|
||||||
fetchIpBans,
|
fetchIpBans,
|
||||||
fetchLateSlipLogs,
|
fetchLateSlipLogs,
|
||||||
@@ -980,17 +982,17 @@ export function AdminCalendarAddEventPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading && !options) {
|
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 (
|
return (
|
||||||
<CalendarEventForm
|
<CalendarEventForm
|
||||||
title="Add Event"
|
title="Add School Calendar Entry"
|
||||||
initial={{
|
initial={{
|
||||||
event_types: options?.event_types ?? [],
|
event_types: options?.event_types ?? [],
|
||||||
defaults: options?.defaults,
|
defaults: options?.defaults,
|
||||||
}}
|
}}
|
||||||
submitLabel="Add Event"
|
submitLabel="Add School Calendar Entry"
|
||||||
submitting={false}
|
submitting={false}
|
||||||
onSubmit={async (payload) => {
|
onSubmit={async (payload) => {
|
||||||
await createAdministratorCalendarEvent(payload as never)
|
await createAdministratorCalendarEvent(payload as never)
|
||||||
@@ -1029,18 +1031,18 @@ export function AdminCalendarEditPage() {
|
|||||||
}, [eventId])
|
}, [eventId])
|
||||||
|
|
||||||
if (loading || !event) {
|
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 (
|
return (
|
||||||
<CalendarEventForm
|
<CalendarEventForm
|
||||||
title="Edit Event"
|
title="Edit School Calendar Entry"
|
||||||
initial={{
|
initial={{
|
||||||
...event,
|
...event,
|
||||||
event_types: options?.event_types ?? [],
|
event_types: options?.event_types ?? [],
|
||||||
defaults: options?.defaults,
|
defaults: options?.defaults,
|
||||||
}}
|
}}
|
||||||
submitLabel="Update Event"
|
submitLabel="Update School Calendar Entry"
|
||||||
submitting={false}
|
submitting={false}
|
||||||
onSubmit={async (payload) => {
|
onSubmit={async (payload) => {
|
||||||
await updateAdministratorCalendarEvent(Number(eventId), payload)
|
await updateAdministratorCalendarEvent(Number(eventId), payload)
|
||||||
@@ -1059,6 +1061,7 @@ export function AdminCalendarViewPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const schoolYear = searchParams.get('school_year') ?? ''
|
const schoolYear = searchParams.get('school_year') ?? ''
|
||||||
|
const semester = searchParams.get('semester') ?? ''
|
||||||
const { options, currentYear } = useSchoolYearOptions({
|
const { options, currentYear } = useSchoolYearOptions({
|
||||||
preferredYear: schoolYear,
|
preferredYear: schoolYear,
|
||||||
})
|
})
|
||||||
@@ -1074,12 +1077,12 @@ export function AdminCalendarViewPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchAdministratorCalendarEvents({
|
const data = await fetchAdministratorCalendarEvents({
|
||||||
schoolYear: schoolYear || null,
|
schoolYear: schoolYear || null,
|
||||||
semester: null,
|
semester: semester || null,
|
||||||
})
|
})
|
||||||
setEvents(data.data?.events ?? [])
|
setEvents(data.data?.events ?? [])
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -1087,16 +1090,16 @@ export function AdminCalendarViewPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [schoolYear])
|
}, [schoolYear, semester])
|
||||||
|
|
||||||
async function onDelete(eventId: number) {
|
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 deleteAdministratorCalendarEvent(eventId)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminParityShell title="Event list">
|
<AdminParityShell title="School Calendar">
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{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">
|
<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)}` : ''}`}
|
to={`/app/administrator/calendar_add_event${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
>
|
>
|
||||||
Add New Event
|
Add School Calendar Entry
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1116,8 +1119,10 @@ export function AdminCalendarViewPage() {
|
|||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const year = String(new FormData(event.currentTarget).get('school_year') ?? '').trim()
|
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()
|
const next = new URLSearchParams()
|
||||||
if (year) next.set('school_year', year)
|
if (year) next.set('school_year', year)
|
||||||
|
if (semester) next.set('semester', semester)
|
||||||
setSearchParams(next)
|
setSearchParams(next)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1136,6 +1141,19 @@ export function AdminCalendarViewPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="d-flex gap-2 align-self-end">
|
||||||
<button className="btn btn-secondary" type="submit">Apply</button>
|
<button className="btn btn-secondary" type="submit">Apply</button>
|
||||||
<button
|
<button
|
||||||
@@ -1159,15 +1177,18 @@ export function AdminCalendarViewPage() {
|
|||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Date</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>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{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 ? (
|
) : 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) => (
|
events.map((row) => (
|
||||||
<tr key={row.id ?? row.title + row.start}>
|
<tr key={row.id ?? row.title + row.start}>
|
||||||
@@ -1176,7 +1197,10 @@ export function AdminCalendarViewPage() {
|
|||||||
<td>{String(row.extendedProps?.event_type ?? '—')}</td>
|
<td>{String(row.extendedProps?.event_type ?? '—')}</td>
|
||||||
<td>{row.description || '—'}</td>
|
<td>{row.description || '—'}</td>
|
||||||
<td>{formatDate(row.start)}</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">
|
<td className="d-flex gap-2 flex-wrap">
|
||||||
{row.id ? (
|
{row.id ? (
|
||||||
<>
|
<>
|
||||||
@@ -1188,7 +1212,7 @@ export function AdminCalendarViewPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted small">Meeting event</span>
|
<span className="text-muted small">Meeting entry</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1350,7 +1374,7 @@ export function AdminClassAssignmentPage() {
|
|||||||
function toggleSection(key: string) {
|
function toggleSection(key: string) {
|
||||||
setExpandedSections((prev) => {
|
setExpandedSections((prev) => {
|
||||||
const next = new Set(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
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2543,7 +2567,7 @@ export function AdminExamDraftsPage() {
|
|||||||
const allowedExtensions = data?.allowed_extensions ?? ['doc', 'docx', 'pdf']
|
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 maxUploadMb = Math.max(1, Math.round((data?.max_upload_bytes ?? 12 * 1024 * 1024) / 1024 / 1024))
|
||||||
const classSections = data?.class_sections ?? []
|
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 legacyByClass = data?.legacy_by_class ?? {}
|
||||||
|
|
||||||
const draftsByClass = useMemo(() => {
|
const draftsByClass = useMemo(() => {
|
||||||
@@ -3465,19 +3489,28 @@ export function AdminPrintNotificationAdminsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminParentProfilePage() {
|
export function AdminParentProfilePage() {
|
||||||
const [guardians, setGuardians] = useState<NotificationAdminRow[]>([])
|
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
|
||||||
const [selectedGuardianId, setSelectedGuardianId] = useState<number | null>(null)
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [families, setFamilies] = useState<Array<Record<string, unknown>>>([])
|
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 [loading, setLoading] = useState(true)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!selectedSchoolYearName) return
|
||||||
;(async () => {
|
;(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await fetchFamilyAdminIndex()
|
const page = Number(searchParams.get('page')) || 1
|
||||||
setGuardians(data.data?.searchGuardians ?? [])
|
const q = searchParams.get('q') ?? ''
|
||||||
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
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)
|
setError(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profiles.')
|
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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [])
|
}, [searchParams, selectedSchoolYearName])
|
||||||
|
|
||||||
async function loadGuardian(guardianId: number) {
|
useEffect(() => {
|
||||||
setSelectedGuardianId(guardianId)
|
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)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await fetchFamilyAdminIndex({ guardianId })
|
const form = new FormData(event.currentTarget)
|
||||||
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
|
const payload = Object.fromEntries(form.entries())
|
||||||
|
const updated = await updateParentProfile(selectedParentId, selectedSchoolYearName, payload)
|
||||||
|
setDetail(updated)
|
||||||
} catch (e) {
|
} 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 (
|
return (
|
||||||
<AdminParityShell title="Parent Profiles">
|
<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}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-lg-4">
|
<div className="col-lg-4">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">Guardians</div>
|
<div className="card-header d-flex justify-content-between">
|
||||||
<div className="list-group list-group-flush" style={{ maxHeight: 520, overflow: 'auto' }}>
|
<span>Parents</span>
|
||||||
{loading ? <div className="list-group-item text-muted">Loading guardians…</div> : guardians.map((guardian) => (
|
<span className="text-muted small">{pagination.total} found</span>
|
||||||
<button key={guardian.id} type="button" className={`list-group-item list-group-item-action ${selectedGuardianId === guardian.id ? 'active' : ''}`} onClick={() => void loadGuardian(guardian.id)}>
|
</div>
|
||||||
<div>{`${guardian.firstname ?? ''} ${guardian.lastname ?? ''}`.trim()}</div>
|
<div className="list-group list-group-flush" style={{ maxHeight: 640, overflow: 'auto' }}>
|
||||||
<div className="small">{guardian.email ?? ''}</div>
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -3517,21 +3617,87 @@ export function AdminParentProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-lg-8">
|
<div className="col-lg-8">
|
||||||
<div className="card">
|
<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">
|
<div className="card-body">
|
||||||
{families.length === 0 ? <p className="text-muted mb-0">Select a guardian to view family details.</p> : families.map((family) => (
|
{!detail ? <p className="text-muted mb-0">Select a parent to view selected-year details.</p> : null}
|
||||||
<div key={String(family.id ?? '')} className="border rounded p-3 mb-3">
|
{detail && parent ? (
|
||||||
<h5 className="mb-1">{String(family.household_name ?? 'Household')}</h5>
|
<div className="d-grid gap-3">
|
||||||
<div className="small text-muted mb-2">{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}</div>
|
<form className="row g-2" onSubmit={saveParentProfile}>
|
||||||
<div className="mb-2"><strong>Phone:</strong> {String(family.primary_phone ?? '—')}</div>
|
{['firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip'].map((field) => (
|
||||||
<div><strong>Students</strong></div>
|
<div className={field === 'address_street' ? 'col-12' : 'col-md-6'} key={field}>
|
||||||
<ul className="mb-0">
|
<label className="form-label text-capitalize">{field.replace('_', ' ')}</label>
|
||||||
{Array.isArray(family.students) && family.students.length > 0 ? family.students.map((student, idx) => (
|
<input className="form-control" name={field} defaultValue={String(parent[field] ?? '')} disabled={isReadOnlyYear} />
|
||||||
<li key={idx}>{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()} {student.class_section_name ? `(${String(student.class_section_name)})` : ''}</li>
|
</div>
|
||||||
)) : <li className="text-muted">No students linked.</li>}
|
))}
|
||||||
</ul>
|
<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>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3933,7 +4099,6 @@ export function AdminSectionsAutoDistributePage() {
|
|||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await runDistribution(row)
|
await runDistribution(row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4318,8 +4483,8 @@ export function AdminStudentProfilesPage() {
|
|||||||
student.registration_grade,
|
student.registration_grade,
|
||||||
student.dob,
|
student.dob,
|
||||||
student.registration_date,
|
student.registration_date,
|
||||||
Boolean(student.is_active) ? 'yes active' : 'no inactive',
|
student.is_active ? 'yes active' : 'no inactive',
|
||||||
Boolean(student.photo_consent) ? 'yes consent' : 'no consent',
|
student.photo_consent ? 'yes consent' : 'no consent',
|
||||||
]
|
]
|
||||||
.map((value) => String(value ?? '').toLowerCase())
|
.map((value) => String(value ?? '').toLowerCase())
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -4391,8 +4556,8 @@ export function AdminStudentProfilesPage() {
|
|||||||
<td>{student.age ?? '—'}</td>
|
<td>{student.age ?? '—'}</td>
|
||||||
<td>{student.gender ?? '—'}</td>
|
<td>{student.gender ?? '—'}</td>
|
||||||
<td>{student.registration_grade ?? '—'}</td>
|
<td>{student.registration_grade ?? '—'}</td>
|
||||||
<td>{Boolean(student.is_active) ? 'Yes' : 'No'}</td>
|
<td>{student.is_active ? 'Yes' : 'No'}</td>
|
||||||
<td>{Boolean(student.photo_consent) ? 'Yes' : 'No'}</td>
|
<td>{student.photo_consent ? 'Yes' : 'No'}</td>
|
||||||
<td>{formatDate(student.registration_date)}</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>
|
<td><button className="btn btn-sm btn-primary" type="button" onClick={() => void openDetails(student)}>View / Edit</button></td>
|
||||||
</tr>
|
</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 { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { CiFlashMessages, type CiFlashMessage } from '../../components/CiPartials'
|
import { CiFlashMessages, type CiFlashMessage } from '../../components/CiPartials'
|
||||||
|
import { SchoolYearSelector, SchoolYearStatusBadge } from '../../components/schoolYear'
|
||||||
|
import { isReadOnlySchoolYear } from '../../lib/schoolYearSelection'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
import {
|
import {
|
||||||
closeSchoolYear,
|
closeSchoolYear,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
validateSchoolYearClose,
|
validateSchoolYearClose,
|
||||||
type CloseSchoolYearPayload,
|
type CloseSchoolYearPayload,
|
||||||
type SchoolYearClosePreview,
|
type SchoolYearClosePreview,
|
||||||
|
type SchoolYearCloseResult,
|
||||||
type SchoolYearCloseValidation,
|
type SchoolYearCloseValidation,
|
||||||
type SchoolYearParentBalancePreview,
|
type SchoolYearParentBalancePreview,
|
||||||
type SchoolYearPromotionPreview,
|
type SchoolYearPromotionPreview,
|
||||||
@@ -75,19 +78,6 @@ function formatMoney(value: number | null | undefined): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'active':
|
|
||||||
return 'success'
|
|
||||||
case 'closed':
|
|
||||||
return 'danger'
|
|
||||||
case 'draft':
|
|
||||||
return 'warning'
|
|
||||||
default:
|
|
||||||
return 'secondary'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function suggestNextName(name: string): string {
|
function suggestNextName(name: string): string {
|
||||||
const fullRange = name.match(/^(\d{4})\s*-\s*(\d{4})$/)
|
const fullRange = name.match(/^(\d{4})\s*-\s*(\d{4})$/)
|
||||||
if (fullRange) {
|
if (fullRange) {
|
||||||
@@ -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() {
|
export function SchoolYearsManagementPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [years, setYears] = useState<SchoolYearRecord[]>([])
|
const [years, setYears] = useState<SchoolYearRecord[]>([])
|
||||||
@@ -143,6 +213,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
|
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
|
||||||
const [closeValidation, setCloseValidation] = useState<SchoolYearCloseValidation | null>(null)
|
const [closeValidation, setCloseValidation] = useState<SchoolYearCloseValidation | null>(null)
|
||||||
const [closePreview, setClosePreview] = useState<SchoolYearClosePreview | null>(null)
|
const [closePreview, setClosePreview] = useState<SchoolYearClosePreview | null>(null)
|
||||||
|
const [closeResult, setCloseResult] = useState<SchoolYearCloseResult | null>(null)
|
||||||
const [createForm, setCreateForm] = useState<YearFormState>(emptyYearForm())
|
const [createForm, setCreateForm] = useState<YearFormState>(emptyYearForm())
|
||||||
const [editForm, setEditForm] = useState<YearFormState>(emptyYearForm())
|
const [editForm, setEditForm] = useState<YearFormState>(emptyYearForm())
|
||||||
const [closeForm, setCloseForm] = useState<CloseFormState>(emptyCloseForm())
|
const [closeForm, setCloseForm] = useState<CloseFormState>(emptyCloseForm())
|
||||||
@@ -150,7 +221,15 @@ export function SchoolYearsManagementPage() {
|
|||||||
const [busyEdit, setBusyEdit] = useState(false)
|
const [busyEdit, setBusyEdit] = useState(false)
|
||||||
const [busyOpenId, setBusyOpenId] = useState<number | null>(null)
|
const [busyOpenId, setBusyOpenId] = useState<number | null>(null)
|
||||||
const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | 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(
|
const activeYear = useMemo(
|
||||||
() => years.find((year) => Boolean(year.is_current)) ?? null,
|
() => years.find((year) => Boolean(year.is_current)) ?? null,
|
||||||
@@ -159,13 +238,13 @@ export function SchoolYearsManagementPage() {
|
|||||||
|
|
||||||
const selectedYear = useMemo(() => {
|
const selectedYear = useMemo(() => {
|
||||||
if (years.length === 0) return null
|
if (years.length === 0) return null
|
||||||
if (selectedYearId != null) {
|
if (selectedYearName) {
|
||||||
return years.find((year) => year.id === selectedYearId) ?? null
|
return years.find((year) => year.name === selectedYearName) ?? null
|
||||||
}
|
}
|
||||||
return activeYear ?? years[0] ?? 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)
|
setLoading(true)
|
||||||
setLoadingMessage('Loading school years…')
|
setLoadingMessage('Loading school years…')
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -175,15 +254,17 @@ export function SchoolYearsManagementPage() {
|
|||||||
setYears(list)
|
setYears(list)
|
||||||
|
|
||||||
const nextSelectedId =
|
const nextSelectedId =
|
||||||
preferredYearId ??
|
preferredYearName ??
|
||||||
(selectedYearId != null && list.some((year) => year.id === selectedYearId)
|
(selectedYearName && list.some((year) => year.name === selectedYearName)
|
||||||
? selectedYearId
|
? selectedYearName
|
||||||
: list.find((year) => year.is_current)?.id ?? list[0]?.id ?? null)
|
: list.find((year) => year.is_current)?.name ?? list[0]?.name ?? null)
|
||||||
|
|
||||||
if (nextSelectedId != null) {
|
if (nextSelectedId != null) {
|
||||||
setSearchParams((current) => {
|
setSearchParams((current) => {
|
||||||
const next = new URLSearchParams(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
|
return next
|
||||||
}, { replace: true })
|
}, { replace: true })
|
||||||
}
|
}
|
||||||
@@ -213,6 +294,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
setParentBalances(null)
|
setParentBalances(null)
|
||||||
setCloseValidation(null)
|
setCloseValidation(null)
|
||||||
setClosePreview(null)
|
setClosePreview(null)
|
||||||
|
setCloseResult(null)
|
||||||
setError(normalizeApiError(loadError, 'Unable to load school years.'))
|
setError(normalizeApiError(loadError, 'Unable to load school years.'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -246,7 +328,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const nextSummary = await fetchSchoolYearSummary(selectedYear.id)
|
const nextSummary = await fetchSchoolYearSummary(selectedYear.name)
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSummary(nextSummary)
|
setSummary(nextSummary)
|
||||||
}
|
}
|
||||||
@@ -281,14 +363,17 @@ export function SchoolYearsManagementPage() {
|
|||||||
setMessages([{ type, message }])
|
setMessages([{ type, message }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectYear(yearId: number) {
|
function selectYear(yearName: string) {
|
||||||
setSearchParams((current) => {
|
setSearchParams((current) => {
|
||||||
const next = new URLSearchParams(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
|
return next
|
||||||
})
|
})
|
||||||
setPromotionPreview(null)
|
setPromotionPreview(null)
|
||||||
setParentBalances(null)
|
setParentBalances(null)
|
||||||
|
setCloseResult(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(event: FormEvent) {
|
async function handleCreate(event: FormEvent) {
|
||||||
@@ -299,7 +384,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
const created = await createSchoolYear(createForm)
|
const created = await createSchoolYear(createForm)
|
||||||
pushMessage('success', `Draft school year ${created.name} created.`)
|
pushMessage('success', `Draft school year ${created.name} created.`)
|
||||||
setCreateForm(emptyYearForm())
|
setCreateForm(emptyYearForm())
|
||||||
await refreshYears(created.id)
|
await refreshYears(created.name)
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
setError(normalizeApiError(saveError, 'Unable to create school year.'))
|
setError(normalizeApiError(saveError, 'Unable to create school year.'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -313,9 +398,9 @@ export function SchoolYearsManagementPage() {
|
|||||||
setBusyEdit(true)
|
setBusyEdit(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const updated = await updateSchoolYear(selectedYear.id, editForm)
|
const updated = await updateSchoolYear(selectedYear.name, editForm)
|
||||||
pushMessage('success', `School year ${updated.name} updated.`)
|
pushMessage('success', `School year ${updated.name} updated.`)
|
||||||
await refreshYears(updated.id)
|
await refreshYears(updated.name)
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
setError(normalizeApiError(saveError, 'Unable to update school year.'))
|
setError(normalizeApiError(saveError, 'Unable to update school year.'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -327,11 +412,11 @@ export function SchoolYearsManagementPage() {
|
|||||||
setBusyOpenId(year.id)
|
setBusyOpenId(year.id)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const reopened = await reopenSchoolYear(year.id)
|
const reopened = await reopenSchoolYear(year.name)
|
||||||
pushMessage('success', `School year ${reopened.name} is now active.`)
|
pushMessage('success', `School year ${reopened.name} is now active.`)
|
||||||
setCloseValidation(null)
|
setCloseValidation(null)
|
||||||
setClosePreview(null)
|
setClosePreview(null)
|
||||||
await refreshYears(reopened.id)
|
await refreshYears(reopened.name)
|
||||||
} catch (actionError) {
|
} catch (actionError) {
|
||||||
setError(normalizeApiError(actionError, 'Unable to open school year.'))
|
setError(normalizeApiError(actionError, 'Unable to open school year.'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -345,10 +430,11 @@ export function SchoolYearsManagementPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const preview = await fetchSchoolYearPromotionPreview(
|
const preview = await fetchSchoolYearPromotionPreview(
|
||||||
selectedYear.id,
|
selectedYear.name,
|
||||||
suggestNextName(selectedYear.name),
|
suggestNextName(selectedYear.name),
|
||||||
)
|
)
|
||||||
setPromotionPreview(preview)
|
setPromotionPreview(preview)
|
||||||
|
setPromotionPage(1)
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(normalizeApiError(loadError, 'Unable to load promotion preview.'))
|
setError(normalizeApiError(loadError, 'Unable to load promotion preview.'))
|
||||||
}
|
}
|
||||||
@@ -360,23 +446,25 @@ export function SchoolYearsManagementPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const balances = await fetchSchoolYearParentBalances(
|
const balances = await fetchSchoolYearParentBalances(
|
||||||
selectedYear.id,
|
selectedYear.name,
|
||||||
suggestNextName(selectedYear.name),
|
suggestNextName(selectedYear.name),
|
||||||
)
|
)
|
||||||
setParentBalances(balances)
|
setParentBalances(balances)
|
||||||
|
setBalancePage(1)
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(normalizeApiError(loadError, 'Unable to load parent balance preview.'))
|
setError(normalizeApiError(loadError, 'Unable to load parent balance preview.'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleValidateClose() {
|
async function handleValidateClose() {
|
||||||
if (!activeYear) return
|
if (!selectedYear?.is_current) return
|
||||||
setBusyCloseAction('validate')
|
setBusyCloseAction('validate')
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
|
const validation = await validateSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm))
|
||||||
setCloseValidation(validation)
|
setCloseValidation(validation)
|
||||||
setClosePreview(null)
|
setClosePreview(null)
|
||||||
|
setCloseResult(null)
|
||||||
} catch (actionError) {
|
} catch (actionError) {
|
||||||
setError(normalizeApiError(actionError, 'Unable to validate school year closure.'))
|
setError(normalizeApiError(actionError, 'Unable to validate school year closure.'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -385,13 +473,14 @@ export function SchoolYearsManagementPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePreviewClose() {
|
async function handlePreviewClose() {
|
||||||
if (!activeYear) return
|
if (!selectedYear?.is_current) return
|
||||||
setBusyCloseAction('preview')
|
setBusyCloseAction('preview')
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
|
const preview = await previewSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm))
|
||||||
setCloseValidation(preview.validation)
|
setCloseValidation(preview.validation)
|
||||||
setClosePreview(preview)
|
setClosePreview(preview)
|
||||||
|
setCloseResult(null)
|
||||||
} catch (actionError) {
|
} catch (actionError) {
|
||||||
setError(normalizeApiError(actionError, 'Unable to preview school year closure.'))
|
setError(normalizeApiError(actionError, 'Unable to preview school year closure.'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -401,21 +490,22 @@ export function SchoolYearsManagementPage() {
|
|||||||
|
|
||||||
async function handleClose(event: FormEvent) {
|
async function handleClose(event: FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!activeYear) return
|
if (!selectedYear?.is_current) return
|
||||||
setBusyCloseAction('close')
|
setBusyCloseAction('close')
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await closeSchoolYear(activeYear.id, closePayloadFrom(closeForm))
|
const result = await closeSchoolYear(selectedYear.name, closePayloadFrom(closeForm))
|
||||||
pushMessage(
|
pushMessage(
|
||||||
'success',
|
'success',
|
||||||
`Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`,
|
`Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`,
|
||||||
)
|
)
|
||||||
setCloseValidation(null)
|
setCloseValidation(null)
|
||||||
setClosePreview(null)
|
setClosePreview(null)
|
||||||
|
setCloseResult(result)
|
||||||
setPromotionPreview(null)
|
setPromotionPreview(null)
|
||||||
setParentBalances(null)
|
setParentBalances(null)
|
||||||
setCloseForm(emptyCloseForm())
|
setCloseForm(emptyCloseForm())
|
||||||
await refreshYears(result.new_school_year.id)
|
await refreshYears(result.new_school_year.name)
|
||||||
} catch (actionError) {
|
} catch (actionError) {
|
||||||
setError(normalizeApiError(actionError, 'Unable to close school year.'))
|
setError(normalizeApiError(actionError, 'Unable to close school year.'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -428,6 +518,14 @@ export function SchoolYearsManagementPage() {
|
|||||||
const balanceRows = parentBalances?.rows ?? []
|
const balanceRows = parentBalances?.rows ?? []
|
||||||
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
|
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
|
||||||
const closeBalanceRows = closePreview?.parent_balances.rows ?? []
|
const closeBalanceRows = closePreview?.parent_balances.rows ?? []
|
||||||
|
const 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 (
|
return (
|
||||||
<div className="container-fluid py-4">
|
<div className="container-fluid py-4">
|
||||||
@@ -453,8 +551,11 @@ export function SchoolYearsManagementPage() {
|
|||||||
parent balance carryover, and year closure through the matching Laravel API routes.
|
parent balance carryover, and year closure through the matching Laravel API routes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted small">
|
<div className="d-flex flex-column align-items-end gap-2">
|
||||||
Uses <code>/api/v1/school-years</code> with JWT <code>Authorization: Bearer</code>.
|
<SchoolYearSelector />
|
||||||
|
<div className="text-muted small">
|
||||||
|
Uses <code>/api/v1/school-years</code> with JWT <code>Authorization: Bearer</code>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -482,7 +583,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-link p-0 fw-semibold text-decoration-none"
|
className="btn btn-link p-0 fw-semibold text-decoration-none"
|
||||||
onClick={() => selectYear(year.id)}
|
onClick={() => selectYear(year.name)}
|
||||||
>
|
>
|
||||||
{year.name}
|
{year.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -490,18 +591,22 @@ export function SchoolYearsManagementPage() {
|
|||||||
{toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
|
{toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`badge text-bg-${statusBadge(year.status)}`}>
|
<SchoolYearStatusBadge status={year.status} />
|
||||||
{year.status.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="d-flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm btn-outline-primary"
|
className="btn btn-sm btn-outline-primary"
|
||||||
onClick={() => selectYear(year.id)}
|
onClick={() => selectYear(year.name)}
|
||||||
>
|
>
|
||||||
Inspect
|
Inspect
|
||||||
</button>
|
</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 ? (
|
{!year.is_current ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -604,9 +709,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedYear ? (
|
{selectedYear ? (
|
||||||
<span className={`badge text-bg-${statusBadge(selectedYear.status)}`}>
|
<SchoolYearStatusBadge status={selectedYear.status} />
|
||||||
{selectedYear.status.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -630,14 +733,14 @@ export function SchoolYearsManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
<div className="border rounded p-3 h-100">
|
<div className="border rounded p-3 h-100">
|
||||||
<div className="small text-muted text-uppercase">Parents with balances</div>
|
<div className="small text-muted text-uppercase">Unpaid carryover parents</div>
|
||||||
<div className="display-6">{summaryCards.parents_with_balances}</div>
|
<div className="display-6">{summaryCards.parents_with_positive_balance ?? summaryCards.parents_with_balances}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
<div className="border rounded p-3 h-100">
|
<div className="border rounded p-3 h-100">
|
||||||
<div className="small text-muted text-uppercase">Net balance impact</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -645,9 +748,28 @@ export function SchoolYearsManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedYear ? (
|
||||||
|
<div className="d-flex flex-wrap gap-2 mb-4">
|
||||||
|
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/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 shadow-sm mb-4">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="h5 mb-3">Edit Selected Year</h2>
|
<h2 className="h5 mb-3">Edit Selected Year</h2>
|
||||||
|
{selectedYearReadOnly ? (
|
||||||
|
<div className="alert alert-warning small">
|
||||||
|
This year is closed or archived. Metadata editing is disabled here; backend guards must reject the write anyway.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{!selectedYear ? (
|
{!selectedYear ? (
|
||||||
<p className="text-muted mb-0">Select a school year to edit its metadata.</p>
|
<p className="text-muted mb-0">Select a school year to edit its metadata.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -660,6 +782,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
id="edit_school_year_name"
|
id="edit_school_year_name"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={editForm.name}
|
value={editForm.name}
|
||||||
|
disabled={selectedYearReadOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setEditForm((current) => ({ ...current, name: event.target.value }))
|
setEditForm((current) => ({ ...current, name: event.target.value }))
|
||||||
}
|
}
|
||||||
@@ -676,6 +799,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
className="form-control"
|
className="form-control"
|
||||||
type="date"
|
type="date"
|
||||||
value={editForm.start_date}
|
value={editForm.start_date}
|
||||||
|
disabled={selectedYearReadOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setEditForm((current) => ({ ...current, start_date: event.target.value }))
|
setEditForm((current) => ({ ...current, start_date: event.target.value }))
|
||||||
}
|
}
|
||||||
@@ -691,6 +815,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
className="form-control"
|
className="form-control"
|
||||||
type="date"
|
type="date"
|
||||||
value={editForm.end_date}
|
value={editForm.end_date}
|
||||||
|
disabled={selectedYearReadOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setEditForm((current) => ({ ...current, end_date: event.target.value }))
|
setEditForm((current) => ({ ...current, end_date: event.target.value }))
|
||||||
}
|
}
|
||||||
@@ -702,7 +827,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
Safe renames depend on whether finance and enrollment records already reference
|
Safe renames depend on whether finance and enrollment records already reference
|
||||||
the existing year label.
|
the existing year label.
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-primary mt-3" disabled={busyEdit}>
|
<button type="submit" className="btn btn-primary mt-3" disabled={busyEdit || selectedYearReadOnly}>
|
||||||
{busyEdit ? 'Saving…' : 'Save changes'}
|
{busyEdit ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -752,6 +877,20 @@ export function SchoolYearsManagementPage() {
|
|||||||
{promotionPreview.summary.total_students} students scanned,{' '}
|
{promotionPreview.summary.total_students} students scanned,{' '}
|
||||||
{promotionPreview.summary.hold} hold rows.
|
{promotionPreview.summary.hold} hold rows.
|
||||||
</div>
|
</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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle mb-0">
|
<table className="table table-sm align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -770,7 +909,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
promotionRows.slice(0, 8).map((row) => (
|
pagedPromotionRows.rows.map((row) => (
|
||||||
<tr key={row.student_id}>
|
<tr key={row.student_id}>
|
||||||
<td>
|
<td>
|
||||||
<div>{row.student_name}</div>
|
<div>{row.student_name}</div>
|
||||||
@@ -802,31 +941,54 @@ export function SchoolYearsManagementPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="small text-muted mb-3">
|
<div className="small text-muted mb-3">
|
||||||
{parentBalances.summary.parents_with_non_zero_balance} parents with
|
Source year: {parentBalances.from_school_year}; target year: {parentBalances.to_school_year}.{' '}
|
||||||
balances, net ${formatMoney(parentBalances.summary.net_balance_to_transfer)}.
|
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>
|
</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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle mb-0">
|
<table className="table table-sm align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Parent</th>
|
<th>Parent</th>
|
||||||
<th>Students</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{balanceRows.length === 0 ? (
|
{balanceRows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="text-muted">
|
<td colSpan={7} className="text-muted">
|
||||||
No rows found.
|
No rows found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
balanceRows.slice(0, 8).map((row) => (
|
pagedBalanceRows.rows.map((row) => (
|
||||||
<tr key={row.parent_id}>
|
<tr key={row.parent_id}>
|
||||||
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
|
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
|
||||||
<td>{row.student_names?.join(', ') || '—'}</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>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -845,20 +1007,39 @@ export function SchoolYearsManagementPage() {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
||||||
<div>
|
<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">
|
<div className="text-muted small">
|
||||||
Validate, preview, and close the current year through the transactional
|
Validate, preview, and close the inspected school year through the transactional
|
||||||
school-year closure endpoints.
|
school-year closure endpoints. Only the current active year can be closed.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeYear ? (
|
{selectedYearIsActive && selectedYear ? (
|
||||||
<span className="badge text-bg-success">{activeYear.name}</span>
|
<span className="badge text-bg-success">{selectedYear.name}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!activeYear ? (
|
{!selectedYear ? (
|
||||||
<p className="text-muted mb-0">No active school year is available to close.</p>
|
<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)}>
|
<form onSubmit={(event) => void handleClose(event)}>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
@@ -922,9 +1103,12 @@ export function SchoolYearsManagementPage() {
|
|||||||
confirmation: event.target.value,
|
confirmation: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder={`CLOSE ${activeYear.name}`}
|
placeholder={requiredConfirmation}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div className={confirmationMatches ? 'form-text' : 'form-text text-danger'}>
|
||||||
|
Type exactly <code>{requiredConfirmation}</code>. Button-click roulette is not a control.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4 d-flex align-items-end">
|
<div className="col-md-4 d-flex align-items-end">
|
||||||
<div className="form-check mb-2">
|
<div className="form-check mb-2">
|
||||||
@@ -951,7 +1135,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={busyCloseAction === 'validate'}
|
disabled={busyCloseAction === 'validate' || !selectedYearIsActive}
|
||||||
onClick={() => void handleValidateClose()}
|
onClick={() => void handleValidateClose()}
|
||||||
>
|
>
|
||||||
{busyCloseAction === 'validate' ? 'Validating…' : 'Validate'}
|
{busyCloseAction === 'validate' ? 'Validating…' : 'Validate'}
|
||||||
@@ -959,16 +1143,21 @@ export function SchoolYearsManagementPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-secondary"
|
className="btn btn-outline-secondary"
|
||||||
disabled={busyCloseAction === 'preview'}
|
disabled={busyCloseAction === 'preview' || !selectedYearIsActive}
|
||||||
onClick={() => void handlePreviewClose()}
|
onClick={() => void handlePreviewClose()}
|
||||||
>
|
>
|
||||||
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
|
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-danger" disabled={busyCloseAction === 'close'}>
|
<button
|
||||||
{busyCloseAction === 'close' ? 'Closing…' : `Close ${activeYear.name}`}
|
type="submit"
|
||||||
|
className="btn btn-danger"
|
||||||
|
disabled={busyCloseAction === 'close' || !confirmationMatches || !selectedYearIsActive}
|
||||||
|
>
|
||||||
|
{busyCloseAction === 'close' ? 'Closing…' : `Close ${selectedYear.name}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{closeValidation ? (
|
{closeValidation ? (
|
||||||
@@ -984,10 +1173,10 @@ export function SchoolYearsManagementPage() {
|
|||||||
Missing decisions: <strong>{closeValidation.promotion_summary.hold}</strong>
|
Missing decisions: <strong>{closeValidation.promotion_summary.hold}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<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>
|
||||||
<div className="col-md-3">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{closeValidation.errors.length > 0 ? (
|
{closeValidation.errors.length > 0 ? (
|
||||||
@@ -1007,12 +1196,38 @@ export function SchoolYearsManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{closeResult ? (
|
||||||
|
<div className="alert alert-success mt-4">
|
||||||
|
<div className="fw-semibold mb-2">Completion report</div>
|
||||||
|
<div className="row g-3 small">
|
||||||
|
<div className="col-md-6">Closed year: <strong>{closeResult.closed_school_year.name}</strong></div>
|
||||||
|
<div className="col-md-6">New active year: <strong>{closeResult.new_school_year.name}</strong></div>
|
||||||
|
<div className="col-md-6">Promotions: <strong>{Object.values(closeResult.promotion_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}</strong></div>
|
||||||
|
<div className="col-md-6">Balance transfer rows: <strong>{Object.values(closeResult.balance_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{closePreview ? (
|
{closePreview ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
<div className="border rounded p-3 h-100">
|
<div className="border rounded p-3 h-100">
|
||||||
<h3 className="h6">Closure promotion preview</h3>
|
<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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle mb-0">
|
<table className="table table-sm align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -1023,7 +1238,7 @@ export function SchoolYearsManagementPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{closePromotionRows.slice(0, 8).map((row) => (
|
{pagedClosePromotionRows.rows.map((row) => (
|
||||||
<tr key={`close-${row.student_id}`}>
|
<tr key={`close-${row.student_id}`}>
|
||||||
<td>{row.student_name}</td>
|
<td>{row.student_name}</td>
|
||||||
<td>{row.promotion_action}</td>
|
<td>{row.promotion_action}</td>
|
||||||
@@ -1038,21 +1253,44 @@ export function SchoolYearsManagementPage() {
|
|||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
<div className="border rounded p-3 h-100">
|
<div className="border rounded p-3 h-100">
|
||||||
<h3 className="h6">Closure balance preview</h3>
|
<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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm align-middle mb-0">
|
<table className="table table-sm align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Parent</th>
|
<th>Parent</th>
|
||||||
<th>Students</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{closeBalanceRows.slice(0, 8).map((row) => (
|
{pagedCloseBalanceRows.rows.map((row) => (
|
||||||
<tr key={`balance-${row.parent_id}`}>
|
<tr key={`balance-${row.parent_id}`}>
|
||||||
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
|
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
|
||||||
<td>{row.student_names?.join(', ') || '—'}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
fetchClassProgressGroups,
|
fetchClassProgressGroups,
|
||||||
fetchClassProgressMeta,
|
fetchClassProgressMeta,
|
||||||
@@ -20,6 +20,9 @@ export function ClassProgressListPage() {
|
|||||||
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
|
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const navigate = useNavigate()
|
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). */
|
/** Server may later expose `low_progress_section_ids`; until then keep empty (CI disables button). */
|
||||||
const lowProgressSectionIds: number[] = []
|
const lowProgressSectionIds: number[] = []
|
||||||
@@ -33,10 +36,12 @@ export function ClassProgressListPage() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [meta, sections, groups] = await Promise.all([
|
const [meta, sections, groups] = await Promise.all([
|
||||||
fetchClassProgressMeta(),
|
fetchClassProgressMeta({ schoolYear: selectedSchoolYear, semester: selectedSemester }),
|
||||||
fetchClassSections({ perPage: 200, withStudents: true }),
|
fetchClassSections({ perPage: 200, withStudents: true, schoolYear: selectedSchoolYear, semester: selectedSemester }),
|
||||||
fetchClassProgressGroups({
|
fetchClassProgressGroups({
|
||||||
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
|
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
|
||||||
|
schoolYear: selectedSchoolYear,
|
||||||
|
semester: selectedSemester,
|
||||||
status: filters.status || null,
|
status: filters.status || null,
|
||||||
weekStart: filters.weekStart || null,
|
weekStart: filters.weekStart || null,
|
||||||
weekEnd: filters.weekEnd || null,
|
weekEnd: filters.weekEnd || null,
|
||||||
@@ -55,7 +60,7 @@ export function ClassProgressListPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [])
|
}, [selectedSchoolYear, selectedSemester])
|
||||||
|
|
||||||
const groupsBySection = useMemo(() => {
|
const groupsBySection = useMemo(() => {
|
||||||
const m = new Map<number, ClassProgressGroupRow[]>()
|
const m = new Map<number, ClassProgressGroupRow[]>()
|
||||||
@@ -69,6 +74,26 @@ export function ClassProgressListPage() {
|
|||||||
return m
|
return m
|
||||||
}, [items])
|
}, [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
|
const subjectCount = Object.keys(subjectSections).length
|
||||||
|
|
||||||
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
|
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
|
||||||
@@ -218,11 +243,11 @@ export function ClassProgressListPage() {
|
|||||||
|
|
||||||
<div className="card shadow-sm mt-5">
|
<div className="card shadow-sm mt-5">
|
||||||
<div className="card-body pt-4">
|
<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="text-center text-muted py-4">No class sections found.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="accordion" id="adminProgressAccordion">
|
<div className="accordion" id="adminProgressAccordion">
|
||||||
{classSections.map((cs) => {
|
{displaySections.map((cs) => {
|
||||||
const sectionId = Number(cs.class_section_id ?? 0)
|
const sectionId = Number(cs.class_section_id ?? 0)
|
||||||
const sectionName = cs.class_section_name ?? 'Unknown Section'
|
const sectionName = cs.class_section_name ?? 'Unknown Section'
|
||||||
const sectionGroups = groupsBySection.get(sectionId) ?? []
|
const sectionGroups = groupsBySection.get(sectionId) ?? []
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ export function FamilyLegacyImportPage() {
|
|||||||
{meta?.instructions_html ? (
|
{meta?.instructions_html ? (
|
||||||
<div
|
<div
|
||||||
className="small"
|
className="small"
|
||||||
// eslint-disable-next-line react/no-danger -- API-provided HTML when present
|
|
||||||
dangerouslySetInnerHTML={{ __html: meta.instructions_html }}
|
dangerouslySetInnerHTML={{ __html: meta.instructions_html }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function GradingDecisionsPage() {
|
|||||||
totals.Other += 1
|
totals.Other += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Boolean(row.is_trophy)) {
|
if (row.is_trophy) {
|
||||||
totals.Trophy += 1
|
totals.Trophy += 1
|
||||||
gender.trophy[bucket] += 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 { ParentParityShell } from './ParentParityShell'
|
||||||
import {
|
import {
|
||||||
fetchAttendanceReportForm,
|
fetchAttendanceReportForm,
|
||||||
@@ -9,7 +10,9 @@ import {
|
|||||||
fetchParentRegistrationOverview,
|
fetchParentRegistrationOverview,
|
||||||
submitParentAttendanceReport,
|
submitParentAttendanceReport,
|
||||||
submitContactMessage,
|
submitContactMessage,
|
||||||
|
updateParentEnrollments,
|
||||||
} from '../../api/session'
|
} from '../../api/session'
|
||||||
|
import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears'
|
||||||
import type {
|
import type {
|
||||||
ParentAttendanceReportFormResponse,
|
ParentAttendanceReportFormResponse,
|
||||||
ParentEnrollmentStudent,
|
ParentEnrollmentStudent,
|
||||||
@@ -18,6 +21,7 @@ import type {
|
|||||||
ParentRegistrationOverviewResponse,
|
ParentRegistrationOverviewResponse,
|
||||||
} from '../../api/types'
|
} from '../../api/types'
|
||||||
import { useAuth } from '../../auth/AuthProvider'
|
import { useAuth } from '../../auth/AuthProvider'
|
||||||
|
import { useSchoolYear } from '../../context/SchoolYearContext'
|
||||||
|
|
||||||
/** `Views/parent/contact.php` — POST /api/v1/contact */
|
/** `Views/parent/contact.php` — POST /api/v1/contact */
|
||||||
export function ParentContactPage() {
|
export function ParentContactPage() {
|
||||||
@@ -152,21 +156,36 @@ export function ParentContactPage() {
|
|||||||
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
|
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
|
||||||
export function ParentEnrollClassesPage() {
|
export function ParentEnrollClassesPage() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
||||||
const [year, setYear] = useState<string | null>(null)
|
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 [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
|
||||||
|
const [actions, setActions] = useState<Record<number, 'enroll' | 'withdraw'>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await fetchParentEnrollments(null)
|
const requestedYear = new URLSearchParams(window.location.search).get('school_year')
|
||||||
|
const data = await fetchParentEnrollments(requestedYear)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setSchoolYears(data.schoolYears ?? [])
|
setSchoolYears(data.schoolYears ?? [])
|
||||||
setYear(data.selectedYear ?? null)
|
setYear(data.selectedYear ?? null)
|
||||||
|
setDates({
|
||||||
|
withdrawalDeadline: data.withdrawalDeadline,
|
||||||
|
lastDayOfRegistration: data.lastDayOfRegistration,
|
||||||
|
schoolStartDate: data.schoolStartDate,
|
||||||
|
})
|
||||||
setStudents(data.students ?? [])
|
setStudents(data.students ?? [])
|
||||||
|
setActions({})
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
|
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 (
|
return (
|
||||||
<ParentParityShell
|
<ParentParityShell
|
||||||
title="Enroll in classes"
|
title="Enroll in classes"
|
||||||
ciViewFile="enroll_classes.php"
|
ciViewFile="enroll_classes.php"
|
||||||
legacyCiRoutes={['/parent/enroll_classes']}
|
legacyCiRoutes={['/parent/enroll_classes']}
|
||||||
>
|
>
|
||||||
|
{message ? <div className="alert alert-success small">{message}</div> : null}
|
||||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-muted small">Loading…</p>
|
<p className="text-muted small">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<form onSubmit={onSubmit}>
|
||||||
<p className="small text-muted mb-2">
|
<div className="row g-3 align-items-end mb-3">
|
||||||
School year: <code>{year ?? '—'}</code>. Updates use{' '}
|
<div className="col-md-4">
|
||||||
<code>POST /api/v1/parents/enrollments</code> with enroll / withdraw payloads.
|
<label className="form-label" htmlFor="enrollment_school_year">
|
||||||
</p>
|
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">
|
<div className="table-responsive">
|
||||||
<table className="table table-sm table-bordered">
|
<table className="table table-sm table-bordered align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Student</th>
|
<th>Student</th>
|
||||||
<th>Class</th>
|
<th>Class</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th className="text-center">Enroll</th>
|
||||||
|
<th className="text-center">Withdraw</th>
|
||||||
|
<th>Choice</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{students.length === 0 ? (
|
{students.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={2} className="text-muted">
|
<td colSpan={6} className="text-muted">
|
||||||
No students for this overview.
|
No students for this overview.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
students.map((s) => (
|
students.map((student) => {
|
||||||
<tr key={s.id}>
|
const selected = actions[student.id] ?? null
|
||||||
<td>
|
const enrollEnabled = canEnroll(student)
|
||||||
{s.firstname} {s.lastname}
|
const withdrawEnabled = canWithdraw(student)
|
||||||
</td>
|
return (
|
||||||
<td>{s.class_section ?? '—'}</td>
|
<tr key={student.id}>
|
||||||
</tr>
|
<td>
|
||||||
))
|
<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>{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{schoolYears.length > 1 ? (
|
<div className="d-flex flex-wrap gap-2 justify-content-end mt-3">
|
||||||
<p className="small mb-0">Additional years in response: {schoolYears.length}</p>
|
<button
|
||||||
) : null}
|
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>
|
</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 */
|
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
|
||||||
export function ParentPaymentInvoicesPage() {
|
export function ParentPaymentInvoicesPage() {
|
||||||
|
const { selectedSchoolYearName } = useSchoolYear()
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [statementError, setStatementError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [statementLoading, setStatementLoading] = useState(false)
|
||||||
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
|
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
|
||||||
|
const [statement, setStatement] = useState<ParentStatement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!selectedSchoolYearName) return
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await fetchParentInvoices(null)
|
const data = await fetchParentInvoices(selectedSchoolYearName)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setRows(data.invoices ?? [])
|
setRows(data.invoices ?? [])
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -255,53 +548,287 @@ export function ParentPaymentInvoicesPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
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 (
|
return (
|
||||||
<ParentParityShell
|
<ParentParityShell
|
||||||
title="Payments & invoices"
|
title="Payments & invoices"
|
||||||
ciViewFile="payment_view.php"
|
ciViewFile="payment_view.php"
|
||||||
legacyCiRoutes={['/parent/payment']}
|
legacyCiRoutes={['/parent/payment']}
|
||||||
|
fullWidth
|
||||||
>
|
>
|
||||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
{loading ? (
|
|
||||||
<p className="text-muted small">Loading…</p>
|
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||||
) : (
|
<div>
|
||||||
<div className="table-responsive">
|
<div className="text-uppercase text-success fw-semibold small mb-1">Family account</div>
|
||||||
<table className="table table-sm table-striped">
|
<h3 className="h4 mb-1">Payment overview</h3>
|
||||||
<thead>
|
<p className="text-muted mb-0">
|
||||||
<tr>
|
{selectedSchoolYearName ? `School year ${selectedSchoolYearName}` : 'School year not selected'}
|
||||||
<th>#</th>
|
</p>
|
||||||
<th>Invoice</th>
|
|
||||||
<th>Balance</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Due</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-muted">
|
|
||||||
No invoices.
|
|
||||||
</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>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</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 ? (
|
||||||
|
<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 mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Transaction</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
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>
|
</ParentParityShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -515,7 +1042,7 @@ export function ParentReportAttendancePage() {
|
|||||||
<select name="student_id" className="form-control" required>
|
<select name="student_id" className="form-control" required>
|
||||||
<option value="">Select student</option>
|
<option value="">Select student</option>
|
||||||
{Array.isArray(data?.students)
|
{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}>
|
<option key={student.id} value={student.id}>
|
||||||
{student.firstname} {student.lastname}
|
{student.firstname} {student.lastname}
|
||||||
</option>
|
</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 { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { ApiHttpError } from '../../api/http'
|
import { ApiHttpError } from '../../api/http'
|
||||||
import {
|
import {
|
||||||
@@ -49,23 +49,32 @@ export function ManualPayPage() {
|
|||||||
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null)
|
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [editPaymentId, setEditPaymentId] = useState<number | string | null>(null)
|
const [editPaymentId, setEditPaymentId] = useState<number | string | null>(null)
|
||||||
|
const loadSeq = useRef(0)
|
||||||
|
|
||||||
const activeTerm = termFromUrl
|
const activeTerm = termFromUrl
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
(term: string) => {
|
(term: string) => {
|
||||||
|
const requestId = ++loadSeq.current
|
||||||
if (!term.trim()) {
|
if (!term.trim()) {
|
||||||
setData(null)
|
setData(null)
|
||||||
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
fetchManualPayPage({ search_term: term.trim() })
|
fetchManualPayPage({ search_term: term.trim() })
|
||||||
.then(setData)
|
.then((payload) => {
|
||||||
.catch((e: unknown) =>
|
if (requestId === loadSeq.current) setData(payload)
|
||||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.'),
|
})
|
||||||
)
|
.catch((e: unknown) => {
|
||||||
.finally(() => setLoading(false))
|
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])
|
}, [termFromUrl, load])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let t: ReturnType<typeof setTimeout>
|
|
||||||
if (searchInput.trim().length < 2) {
|
if (searchInput.trim().length < 2) {
|
||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
fetchManualPaySuggest(searchInput.trim())
|
fetchManualPaySuggest(searchInput.trim())
|
||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
setSuggestions(
|
setSuggestions(
|
||||||
@@ -104,6 +112,21 @@ export function ManualPayPage() {
|
|||||||
navigate({ pathname: '/app/administrator/payment/manual-pay', search: `?search_term=${encodeURIComponent(q)}` })
|
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) {
|
function pickSuggestion(text: string) {
|
||||||
setSearchInput(text)
|
setSearchInput(text)
|
||||||
setSuggestOpen(false)
|
setSuggestOpen(false)
|
||||||
@@ -184,6 +207,14 @@ export function ManualPayPage() {
|
|||||||
<button className="btn btn-primary" type="submit" disabled={loading}>
|
<button className="btn btn-primary" type="submit" disabled={loading}>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={clearSearch}
|
||||||
|
disabled={loading && !searchInput && !data}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{suggestOpen && suggestions.length > 0 ? (
|
{suggestOpen && suggestions.length > 0 ? (
|
||||||
<div className="list-group position-absolute w-100 shadow-sm" style={{ zIndex: 10 }}>
|
<div className="list-group position-absolute w-100 shadow-sm" style={{ zIndex: 10 }}>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function AdminPrintRequestsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-link btn-sm p-0"
|
className="btn btn-link btn-sm p-0"
|
||||||
onClick={() => openPrintRequestFile(encodeURIComponent(fp))}
|
onClick={() => openPrintRequestFile(id as number | string)}
|
||||||
>
|
>
|
||||||
{fp}
|
{fp}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
fetchReportCardCompleteness,
|
fetchReportCardCompleteness,
|
||||||
fetchReportCardMeta,
|
fetchReportCardMeta,
|
||||||
fetchReportCardStudentHtml,
|
fetchReportCardStudentHtml,
|
||||||
openReportCardDownload,
|
openReportCardClassDownload,
|
||||||
|
openReportCardStudentDownload,
|
||||||
reportCardClassUrl,
|
reportCardClassUrl,
|
||||||
reportCardStudentUrl,
|
reportCardStudentUrl,
|
||||||
type ReportCardMeta,
|
type ReportCardMeta,
|
||||||
@@ -118,14 +119,12 @@ export function ReportCardManagementPage() {
|
|||||||
|
|
||||||
async function downloadStudent() {
|
async function downloadStudent() {
|
||||||
if (!studentId || !schoolYear) return
|
if (!studentId || !schoolYear) return
|
||||||
const url = reportCardStudentUrl(studentId, {
|
|
||||||
school_year: schoolYear,
|
|
||||||
semester,
|
|
||||||
download: true,
|
|
||||||
report_date: reportDate || undefined,
|
|
||||||
})
|
|
||||||
try {
|
try {
|
||||||
await openReportCardDownload(url)
|
await openReportCardStudentDownload(studentId, {
|
||||||
|
school_year: schoolYear,
|
||||||
|
semester,
|
||||||
|
report_date: reportDate || undefined,
|
||||||
|
})
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
||||||
}
|
}
|
||||||
@@ -133,14 +132,12 @@ export function ReportCardManagementPage() {
|
|||||||
|
|
||||||
async function downloadClass() {
|
async function downloadClass() {
|
||||||
if (!classId || !schoolYear) return
|
if (!classId || !schoolYear) return
|
||||||
const url = reportCardClassUrl(classId, {
|
|
||||||
school_year: schoolYear,
|
|
||||||
semester,
|
|
||||||
download: true,
|
|
||||||
report_date: reportDate || undefined,
|
|
||||||
})
|
|
||||||
try {
|
try {
|
||||||
await openReportCardDownload(url)
|
await openReportCardClassDownload(classId, {
|
||||||
|
school_year: schoolYear,
|
||||||
|
semester,
|
||||||
|
report_date: reportDate || undefined,
|
||||||
|
})
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function SlipPreviewListPage() {
|
|||||||
function toggleDate(dateKey: string) {
|
function toggleDate(dateKey: string) {
|
||||||
setExpandedDates((prev) => {
|
setExpandedDates((prev) => {
|
||||||
const next = new Set(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
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import { type FormEvent, useEffect, useState } from 'react'
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createStaff, fetchStaffFormOptions, type RoleOption } from '../../api/staff'
|
import { createStaff, fetchStaffFormOptions, type RoleOption } from '../../api/staff'
|
||||||
|
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
|
||||||
|
import { useSchoolYear } from '../../context/SchoolYearContext'
|
||||||
|
|
||||||
export function StaffCreatePage() {
|
export function StaffCreatePage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
|
||||||
const [roles, setRoles] = useState<RoleOption[]>([])
|
const [roles, setRoles] = useState<RoleOption[]>([])
|
||||||
const [firstname, setFirstname] = useState('')
|
const [firstname, setFirstname] = useState('')
|
||||||
const [lastname, setLastname] = useState('')
|
const [lastname, setLastname] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [roleId, setRoleId] = useState<number>(0)
|
const [roleId, setRoleId] = useState<number>(0)
|
||||||
|
const [status, setStatus] = useState('active')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchStaffFormOptions()
|
const data = await fetchStaffFormOptions({ school_year: selectedSchoolYearName ?? undefined })
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const list = data.roles ?? []
|
const list = data.roles ?? []
|
||||||
setRoles(list)
|
setRoles(list)
|
||||||
@@ -28,17 +33,21 @@ export function StaffCreatePage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [])
|
}, [selectedSchoolYearName])
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (!selectedSchoolYearName) {
|
||||||
|
setError('Please select a school year.')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!roleId) {
|
if (!roleId) {
|
||||||
setError('Please select a role.')
|
setError('Please select a role.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createStaff({ firstname, lastname, email, password, role_id: roleId })
|
await createStaff({ firstname, lastname, email, phone, password, role_id: roleId, status, school_year: selectedSchoolYearName })
|
||||||
navigate('/app/staff')
|
navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unable to create staff.')
|
setError(err instanceof Error ? err.message : 'Unable to create staff.')
|
||||||
}
|
}
|
||||||
@@ -47,6 +56,11 @@ export function StaffCreatePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
<h2>Add Staff Member</h2>
|
<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}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<form onSubmit={(e) => void onSubmit(e)}>
|
<form onSubmit={(e) => void onSubmit(e)}>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -89,6 +103,10 @@ export function StaffCreatePage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="mb-3">
|
||||||
<label className="form-label">Role</label>
|
<label className="form-label">Role</label>
|
||||||
<select
|
<select
|
||||||
@@ -104,7 +122,14 @@ export function StaffCreatePage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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
|
Create
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { type FormEvent, useEffect, useState } from 'react'
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { fetchStaffMember, updateStaff } from '../../api/staff'
|
import { fetchStaffMember, updateStaff } from '../../api/staff'
|
||||||
|
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
|
||||||
|
import { useSchoolYear } from '../../context/SchoolYearContext'
|
||||||
|
|
||||||
export function StaffEditPage() {
|
export function StaffEditPage() {
|
||||||
const { staffId } = useParams()
|
const { staffId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
|
||||||
const id = Number(staffId)
|
const id = Number(staffId)
|
||||||
const [firstname, setFirstname] = useState('')
|
const [firstname, setFirstname] = useState('')
|
||||||
const [lastname, setLastname] = useState('')
|
const [lastname, setLastname] = useState('')
|
||||||
@@ -17,7 +20,7 @@ export function StaffEditPage() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchStaffMember(id)
|
const data = await fetchStaffMember(id, { school_year: selectedSchoolYearName ?? undefined })
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const u = (data.user ?? data.staff ?? {}) as Record<string, unknown>
|
const u = (data.user ?? data.staff ?? {}) as Record<string, unknown>
|
||||||
setFirstname(String(u.firstname ?? ''))
|
setFirstname(String(u.firstname ?? ''))
|
||||||
@@ -32,14 +35,18 @@ export function StaffEditPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id, selectedSchoolYearName])
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
await updateStaff(id, { firstname, lastname, email, phone })
|
if (!selectedSchoolYearName) {
|
||||||
navigate('/app/staff')
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unable to update staff.')
|
setError(err instanceof Error ? err.message : 'Unable to update staff.')
|
||||||
}
|
}
|
||||||
@@ -48,6 +55,11 @@ export function StaffEditPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
<h2>Edit Staff Member</h2>
|
<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}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<form onSubmit={(e) => void onSubmit(e)}>
|
<form onSubmit={(e) => void onSubmit(e)}>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -91,7 +103,7 @@ export function StaffEditPage() {
|
|||||||
placeholder="Enter phone number"
|
placeholder="Enter phone number"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary" disabled={isReadOnlyYear || !selectedSchoolYearName}>
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,51 +1,112 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { CiAcademicFilter } from '../../components/CiPartials'
|
|
||||||
import { fetchStaffIndex, type StaffListRow } from '../../api/staff'
|
import { fetchStaffIndex, type StaffListRow } from '../../api/staff'
|
||||||
|
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
|
||||||
|
import { useSchoolYear } from '../../context/SchoolYearContext'
|
||||||
|
|
||||||
export function StaffIndexPage() {
|
export function StaffIndexPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
|
||||||
const [rows, setRows] = useState<StaffListRow[]>([])
|
const [rows, setRows] = useState<StaffListRow[]>([])
|
||||||
const [issuesCount, setIssuesCount] = useState(0)
|
const [issuesCount, setIssuesCount] = useState(0)
|
||||||
const [schoolYear, setSchoolYear] = useState<string | null>(null)
|
const [schoolYear, setSchoolYear] = useState<string | null>(null)
|
||||||
const [schoolYears, setSchoolYears] = useState<string[]>([])
|
|
||||||
const [semester, setSemester] = useState<string | null>(null)
|
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 [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
const sy = selectedSchoolYearName ?? searchParams.get('school_year') ?? undefined
|
||||||
try {
|
try {
|
||||||
const sy = searchParams.get('school_year') ?? undefined
|
|
||||||
const sem = searchParams.get('semester') ?? 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 ?? [])
|
setRows(data.staff ?? [])
|
||||||
setIssuesCount(Number(data.issues_count ?? 0))
|
setIssuesCount(Number(data.issues_count ?? 0))
|
||||||
setSchoolYear(data.school_year ?? null)
|
setSchoolYear(data.school_year ?? null)
|
||||||
setSemester(data.semester ?? null)
|
setSemester(data.semester ?? null)
|
||||||
setSchoolYears(data.schoolYears ?? [])
|
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Unable to load staff.')
|
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(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [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 (
|
return (
|
||||||
<div className="container-fluid mt-4">
|
<div className="container-fluid mt-4">
|
||||||
<h2 className="text-center mt-4 mb-3">Staff List</h2>
|
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
||||||
<CiAcademicFilter
|
<div>
|
||||||
schoolYears={schoolYears.length > 0 ? schoolYears : schoolYear ? [schoolYear] : []}
|
<h2 className="mb-1">Staff List</h2>
|
||||||
selectedYear={searchParams.get('school_year') ?? schoolYear ?? undefined}
|
<div className="text-muted small">School year: {selectedYear || '—'}</div>
|
||||||
selectedSemester={searchParams.get('semester') ?? semester ?? undefined}
|
</div>
|
||||||
onApply={({ schoolYear: y, semester: sem }) => {
|
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||||
const next = new URLSearchParams()
|
<SchoolYearSelector compact />
|
||||||
if (y) next.set('school_year', y)
|
{!isReadOnlyYear ? (
|
||||||
if (sem) next.set('semester', sem)
|
<Link className="btn btn-primary btn-sm" to={`/app/staff/create${selectedQuery}`}>Add Staff</Link>
|
||||||
setSearchParams(next)
|
) : 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 ? (
|
{issuesCount > 0 ? (
|
||||||
<div className="alert alert-warning" role="alert">
|
<div className="alert alert-warning" role="alert">
|
||||||
@@ -83,7 +144,7 @@ export function StaffIndexPage() {
|
|||||||
<td>{s.active_role ?? ''}</td>
|
<td>{s.active_role ?? ''}</td>
|
||||||
<td>{s.class_section ?? ''}</td>
|
<td>{s.class_section ?? ''}</td>
|
||||||
<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
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function buildHistoryQueryString(qs: string): string {
|
|||||||
if (v === '' || v == null) emptyKeys.push(k)
|
if (v === '' || v == null) emptyKeys.push(k)
|
||||||
})
|
})
|
||||||
emptyKeys.forEach((k) => p.delete(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('per_page')) p.set('per_page', '100')
|
||||||
if (!p.has('sort_by')) p.set('sort_by', 'week_start')
|
if (!p.has('sort_by')) p.set('sort_by', 'week_start')
|
||||||
if (!p.has('sort_dir')) p.set('sort_dir', 'desc')
|
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 [draftWeekStart, setDraftWeekStart] = useState(() => search.get('week_start') ?? '')
|
||||||
const [draftWeekEnd, setDraftWeekEnd] = useState(() => search.get('week_end') ?? '')
|
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 [draftStatus, setDraftStatus] = useState(() => search.get('status') ?? '')
|
||||||
const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc')
|
const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc')
|
||||||
|
|
||||||
@@ -114,22 +114,10 @@ export function TeacherClassProgressHistoryPage() {
|
|||||||
const p = new URLSearchParams(buildHistoryQueryString(qs))
|
const p = new URLSearchParams(buildHistoryQueryString(qs))
|
||||||
setDraftWeekStart(p.get('week_start') ?? '')
|
setDraftWeekStart(p.get('week_start') ?? '')
|
||||||
setDraftWeekEnd(p.get('week_end') ?? '')
|
setDraftWeekEnd(p.get('week_end') ?? '')
|
||||||
setDraftSection(p.get('class_section_id') ?? '')
|
|
||||||
setDraftStatus(p.get('status') ?? '')
|
setDraftStatus(p.get('status') ?? '')
|
||||||
setDraftSortDir(p.get('sort_dir') || 'desc')
|
setDraftSortDir(p.get('sort_dir') || 'desc')
|
||||||
}, [qs])
|
}, [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) {
|
function applyFilters(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const next = new URLSearchParams()
|
const next = new URLSearchParams()
|
||||||
@@ -138,7 +126,6 @@ export function TeacherClassProgressHistoryPage() {
|
|||||||
next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc')
|
next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc')
|
||||||
if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim())
|
if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim())
|
||||||
if (draftWeekEnd.trim()) next.set('week_end', draftWeekEnd.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())
|
if (draftStatus.trim()) next.set('status', draftStatus.trim())
|
||||||
next.delete('page')
|
next.delete('page')
|
||||||
setSearch(next, { replace: true })
|
setSearch(next, { replace: true })
|
||||||
@@ -147,7 +134,6 @@ export function TeacherClassProgressHistoryPage() {
|
|||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
setDraftWeekStart('')
|
setDraftWeekStart('')
|
||||||
setDraftWeekEnd('')
|
setDraftWeekEnd('')
|
||||||
setDraftSection('')
|
|
||||||
setDraftStatus('')
|
setDraftStatus('')
|
||||||
const next = new URLSearchParams()
|
const next = new URLSearchParams()
|
||||||
next.set('per_page', '100')
|
next.set('per_page', '100')
|
||||||
@@ -220,24 +206,6 @@ export function TeacherClassProgressHistoryPage() {
|
|||||||
onChange={(e) => setDraftWeekEnd(e.target.value)}
|
onChange={(e) => setDraftWeekEnd(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="col-md-3 col-lg-2">
|
||||||
<label className="form-label small mb-0" htmlFor="hist-st">
|
<label className="form-label small mb-0" htmlFor="hist-st">
|
||||||
Status
|
Status
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
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 { ApiHttpError, apiFetch } from '../../api/http'
|
||||||
import { TeacherShell } from './TeacherShell'
|
import { TeacherShell } from './TeacherShell'
|
||||||
import { useTeacherResource } from './useTeacherResource'
|
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`. */
|
/** `teacher/class_progress_submit.php` — weekly progress form; POST `/api/v1/teacher/class-progress-submit`. */
|
||||||
export function TeacherClassProgressSubmitPage() {
|
export function TeacherClassProgressSubmitPage() {
|
||||||
const [search] = useSearchParams()
|
const { data: raw, loading, error, reload } = useTeacherResource<unknown>('/class-progress-submit')
|
||||||
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 = useMemo(() => unwrapSubmitForm(raw), [raw])
|
const data = useMemo(() => unwrapSubmitForm(raw), [raw])
|
||||||
|
|
||||||
const subjectSections = data?.subject_sections ?? {}
|
const subjectSections = data?.subject_sections ?? {}
|
||||||
@@ -132,15 +123,6 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
|
|
||||||
const unitPickList = useMemo(() => unitChoices(curriculumIslamic), [curriculumIslamic])
|
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 [weekStart, setWeekStart] = useState('')
|
||||||
const [classSectionId, setClassSectionId] = useState<number | ''>('')
|
const [classSectionId, setClassSectionId] = useState<number | ''>('')
|
||||||
const [covered, setCovered] = useState<Record<string, string>>({})
|
const [covered, setCovered] = useState<Record<string, string>>({})
|
||||||
@@ -172,31 +154,20 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
setSubmitOk(null)
|
setSubmitOk(null)
|
||||||
}, [data, defaults.class_section_id, defaults.week_start, sundayOptions])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (classSectionId === '' && sectionsForClass.length === 1) {
|
if (classSectionId === '' && classes.length === 1) {
|
||||||
const only = Number(sectionsForClass[0].class_section_id ?? 0)
|
const only = Number(classes[0].class_section_id ?? 0)
|
||||||
if (only > 0) setClassSectionId(only)
|
if (only > 0) setClassSectionId(only)
|
||||||
}
|
}
|
||||||
}, [sectionsForClass, classSectionId])
|
}, [classes, classSectionId])
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
if (!weekStart || !classSectionId) return false
|
if (!weekStart) return false
|
||||||
if (!islamicSelectionValid(islamicRows)) return false
|
if (!islamicSelectionValid(islamicRows)) return false
|
||||||
const covIslamic = (covered.islamic ?? '').trim()
|
const covIslamic = (covered.islamic ?? '').trim()
|
||||||
if (covIslamic === '') return false
|
if (covIslamic === '') return false
|
||||||
return true
|
return true
|
||||||
}, [weekStart, classSectionId, islamicRows, covered])
|
}, [weekStart, islamicRows, covered])
|
||||||
|
|
||||||
const updateIslamicRow = useCallback((idx: number, patch: Partial<UnitRow>) => {
|
const updateIslamicRow = useCallback((idx: number, patch: Partial<UnitRow>) => {
|
||||||
setIslamicRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
|
setIslamicRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
|
||||||
@@ -209,7 +180,7 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setSubmitOk(null)
|
setSubmitOk(null)
|
||||||
if (!canSubmit || classSectionId === '') return
|
if (!canSubmit) return
|
||||||
|
|
||||||
const unitIslamic: string[] = []
|
const unitIslamic: string[] = []
|
||||||
const chapterIslamic: string[] = []
|
const chapterIslamic: string[] = []
|
||||||
@@ -240,8 +211,9 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('class_section_id', String(classSectionId))
|
|
||||||
fd.append('week_start', weekStart)
|
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')
|
if (confirmOverwrite) fd.append('confirm_overwrite', '1')
|
||||||
|
|
||||||
for (const slug of slugs) {
|
for (const slug of slugs) {
|
||||||
@@ -282,8 +254,9 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
canSubmit,
|
canSubmit,
|
||||||
classSectionId,
|
|
||||||
weekStart,
|
weekStart,
|
||||||
|
defaults.school_year,
|
||||||
|
defaults.semester,
|
||||||
confirmOverwrite,
|
confirmOverwrite,
|
||||||
slugs,
|
slugs,
|
||||||
covered,
|
covered,
|
||||||
@@ -328,26 +301,6 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{submitOk ? (
|
||||||
<div className="alert alert-success py-2 mb-0" role="status">
|
<div className="alert alert-success py-2 mb-0" role="status">
|
||||||
{submitOk}
|
{submitOk}
|
||||||
@@ -382,29 +335,6 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -671,7 +601,7 @@ export function TeacherClassProgressSubmitPage() {
|
|||||||
</button>
|
</button>
|
||||||
{!canSubmit ? (
|
{!canSubmit ? (
|
||||||
<span className="small text-muted">
|
<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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
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 { fetchAdministratorCalendarEvents, openProtectedApiFile } from '../../api/session'
|
||||||
import type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types'
|
import type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types'
|
||||||
import { TeacherShell } from './TeacherShell'
|
import { TeacherShell } from './TeacherShell'
|
||||||
@@ -128,8 +128,18 @@ type FrontendMeResponse = {
|
|||||||
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
|
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
|
||||||
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
|
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
|
||||||
|
|
||||||
function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null) {
|
function teacherExamStoredFileName(filename?: string | null): string | null {
|
||||||
return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : 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 = {
|
type TeacherAbsenceRow = {
|
||||||
@@ -332,7 +342,9 @@ export function TeacherCompetitionScoresIndexPage() {
|
|||||||
<td>{dateLabel}</td>
|
<td>{dateLabel}</td>
|
||||||
<td>{isLocked ? 'Yes' : 'No'}</td>
|
<td>{isLocked ? 'Yes' : 'No'}</td>
|
||||||
<td>
|
<td>
|
||||||
{isLocked ? (
|
{compId <= 0 ? (
|
||||||
|
<span className="text-muted">Unavailable</span>
|
||||||
|
) : isLocked ? (
|
||||||
<span className="text-muted">Locked</span>
|
<span className="text-muted">Locked</span>
|
||||||
) : (
|
) : (
|
||||||
<Link to={`/app/teacher/competition-scores/${compId}`}>Enter scores</Link>
|
<Link to={`/app/teacher/competition-scores/${compId}`}>Enter scores</Link>
|
||||||
@@ -353,10 +365,12 @@ export function TeacherCompetitionScoresIndexPage() {
|
|||||||
/** `teacher/competition_scores/scores.php` */
|
/** `teacher/competition_scores/scores.php` */
|
||||||
export function TeacherCompetitionScoresDetailPage() {
|
export function TeacherCompetitionScoresDetailPage() {
|
||||||
const { competitionId } = useParams()
|
const { competitionId } = useParams()
|
||||||
const path =
|
const id = Number(competitionId ?? 0)
|
||||||
competitionId && competitionId !== 'undefined'
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
? `/competition-scores/${competitionId}`
|
return <Navigate to="/app/teacher/competition-scores" replace />
|
||||||
: '/competition-scores/0'
|
}
|
||||||
|
|
||||||
|
const path = `/competition-scores/${id}`
|
||||||
return <TeacherApiDumpPage title="Competition scores entry" path={path} />
|
return <TeacherApiDumpPage title="Competition scores entry" path={path} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +757,7 @@ export function TeacherCalendarPage() {
|
|||||||
(row) =>
|
(row) =>
|
||||||
Boolean(row.extendedProps?.notify_teacher) &&
|
Boolean(row.extendedProps?.notify_teacher) &&
|
||||||
Boolean(row.extendedProps?.notify_admin) &&
|
Boolean(row.extendedProps?.notify_admin) &&
|
||||||
!Boolean(row.extendedProps?.notify_parent),
|
!row.extendedProps?.notify_parent,
|
||||||
).length
|
).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1279,7 +1293,7 @@ export function TeacherExamDraftsPage() {
|
|||||||
) : (
|
) : (
|
||||||
(payload?.drafts ?? []).map((draft) => {
|
(payload?.drafts ?? []).map((draft) => {
|
||||||
const teacherFile = teacherExamFilePath('teacher', draft.teacher_file)
|
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 (
|
return (
|
||||||
<tr key={draft.id}>
|
<tr key={draft.id}>
|
||||||
<td>
|
<td>
|
||||||
@@ -1340,7 +1354,7 @@ export function TeacherExamDraftsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
{(payload?.legacy_exams ?? []).map((draft) => {
|
{(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 (
|
return (
|
||||||
<div key={draft.id} className="col-12 col-xl-6">
|
<div key={draft.id} className="col-12 col-xl-6">
|
||||||
<article className="teacher-calendar-agenda-item h-100">
|
<article className="teacher-calendar-agenda-item h-100">
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
/** Laravel API for dev proxy (`/api` → this host). Override: `VITE_PROXY_API=http://localhost:8000 npm run dev`. */
|
/** 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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user