Compare commits

..

27 Commits

Author SHA1 Message Date
root c6d2bc007e fix first production issues
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 40s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 24s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-10 03:43:47 -04:00
root b895c06dc1 fix parent pages and teachers and admin
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-09 13:56:26 -04:00
root 4b78aafa65 fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 30s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-07 01:52:37 -04:00
root b510f534fd security fix and fix parent pages
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 22s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 32s
2026-07-06 02:14:22 -04:00
root b74ddca8b1 add school year and security fix
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 28s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-06 00:55:02 -04:00
root 8cacd0874f fix calendar page
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 32s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 47s
2026-07-05 14:18:49 -04:00
root 405dc3e379 fix(ci): remove GitHub-specific marketplace actions incompatible with Gitea act runner
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 18s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 14s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 15s
- Remove actions/cache@v4 (uses GitHub's artifact API not available in Gitea)
- Remove actions/upload-artifact@v4 (same reason)
- Remove actions/download-artifact@v4 (same reason)
- Deploy job now builds from source instead of downloading artifacts
- Deploy container changed from alpine:3.20 to node:22-alpine for npm build
2026-06-22 00:57:53 -04:00
root 816f4cfaf0 fix: resolve all CI lint errors (9 errors → 0)
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 40s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 36s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
- Move SortTh outside AdminEmergencyContactIndexPage to fix
  react-hooks/static-components (5 errors)
- Fix ternary expressions used as statements in toggleSection
  and toggleDate (2 @typescript-eslint/no-unused-expressions errors)
- Remove stale eslint-disable-next-line react/no-danger comment
  referencing unregistered rule (1 error)
- Fix pre-existing any type in WiredParentScreens student.map
2026-06-22 00:38:47 -04:00
root 7282020444 chore: configure eslint rules and fix lint warnings
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 41s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
- Add eslint rules section downgrading strict react-hooks v7 rules to warn
- Remove unnecessary Boolean() wrappers across multiple pages
- Fix let→const for non-reassigned variable in AdminProgressPages
- Clean up stale eslint-disable-next-line comment and trailing whitespace
- Simplify setTimeout declaration in ManualPayPage
2026-06-22 00:17:18 -04:00
root 1992db65b8 fix(ci): use github.server_url template expression instead of missing GITEA_SERVER_URL env
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 40s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-21 21:42:45 -04:00
root 32875e54a8 ci: trigger pipeline with new default branch
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 2s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 2s
2026-06-21 15:10:35 -04:00
root 14eade0b7e fix(ci): install git via apk before clone in node:22-alpine image
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 2s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 2s
2026-06-21 15:08:32 -04:00
root 1e2345323f fix(ci): replace actions/checkout with git clone + GIT_SSL_NO_VERIFY to bypass self-signed cert
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 1s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 1s
2026-06-21 15:06:54 -04:00
root c3570481c8 fix(ci): merge duplicate push triggers in workflow
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 26s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 40s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-21 12:00:34 -04:00
root 09dbca4767 fix: add alpine container to deploy job for shared hosting compatibility
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 38s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 32s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-20 19:20:45 -04:00
root 2eaf590373 docs: add Gitea CI/CD setup checklist
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 32s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to production (push) Has been skipped
2026-06-20 19:18:40 -04:00
root 4b6232d2ba ci: add Gitea CI/CD workflow for React web client
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 37s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 36s
Web Client CI/CD / Deploy to production (push) Has been skipped
Pipeline includes lint (eslint), build (tsc + vite), and manual
deploy stages.
2026-06-20 19:16:38 -04:00
root cd29394211 fix cicd variables naming 2026-06-12 13:14:53 -04:00
root 1eb45f46a8 add test script to package json 2026-06-12 12:23:43 -04:00
root 665b26df1e remove pupeteer and ad ci gitlab 2026-06-12 01:10:24 -04:00
root 7365c14506 fix inventory 2026-06-11 11:06:41 -04:00
root e8d1b8c1c7 page fixes 2026-06-11 03:22:22 -04:00
root fe1c50878b add test create 3000 users 2026-06-08 01:53:25 -04:00
root b6513ab22f update the new school year model 2026-06-07 20:01:56 -04:00
root 8e79201a3c add school year model 2026-06-07 00:52:04 -04:00
root c4d7a06a17 fix financial and certificates 2026-06-05 01:51:12 -04:00
root 647b96cafc fix attendance 2026-06-04 20:49:24 -04:00
108 changed files with 9933 additions and 2203 deletions
+49
View File
@@ -0,0 +1,49 @@
# Gitea CI/CD — Setup Checklist
After pushing, the pipeline will run automatically. The following steps are required to unlock all features (especially deploy jobs).
## 1. Repository Secrets (Settings → Actions → Secrets)
| Secret Name | Description |
|---|---|
| `SSH_PASSWORD` | SSH password for the deployment server |
| `SSH_USER` | SSH username for the deployment server |
| `SSH_HOST` | IP or domain of the deployment server |
## 2. Repository Variables (Settings → Actions → Variables)
| Variable Name | Default | Description |
|---|---|---|
| `SSH_PORT` | `22` | SSH port for the deployment server |
| `SSH_TARGET_DIR` | — | Absolute path on the server where `dist/` contents should be placed |
| `PRODUCTION_URL` | — | Public URL of the production site |
| `VITE_API_URL` | — | (Optional) API base URL passed at build time |
## 3. Allow Gitea Actions
- Go to **Settings → Actions** in your Gitea repository
- Ensure **"Actions"** are enabled
- If using self-hosted runners, ensure a runner is registered and online
## 4. Optional: Configure Gitea Actions Runner
If no runner is registered yet on your Gitea instance:
```bash
# On the runner machine (Docker-based)
docker run -d \
--name gitea-runner \
-e GITEA_INSTANCE_URL=https://192.168.3.80 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<your-registration-token> \
-e GITEA_RUNNER_NAME=runner-1 \
-v /var/run/docker.sock:/var/run/docker.sock \
gitea/act_runner:latest
```
## Pipeline Stages
| Job | Requires Secrets/Vars | Runs on |
|---|---|---|
| `lint` | None | Push & PR |
| `build` | None | Push & PR |
| `deploy` | SSH secrets + vars | Manual on `develop` |
+132
View File
@@ -0,0 +1,132 @@
# Gitea CI/CD for alrahma_web_client (React + Vite + TypeScript)
#
# Triggers:
# - Push to any branch
# - Pull requests
# - Tags
#
# Stages: lint → build → deploy (manual)
name: Web Client CI/CD
on:
push:
branches: [main, master, develop, 'feature/**', 'fix/**']
tags: ['v*']
pull_request:
branches: [main, master, develop]
env:
CI: true
jobs:
# ──────────────────────────────────────────────
# LINT: ESLint with TypeScript
# ──────────────────────────────────────────────
lint:
name: Lint (ESLint + TypeScript)
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Run ESLint
run: npm run lint
# ──────────────────────────────────────────────
# BUILD: TypeScript + Vite
# ──────────────────────────────────────────────
build:
name: Build (tsc + Vite)
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
# ──────────────────────────────────────────────
# DEPLOY (manual): to shared hosting via SSH
# ──────────────────────────────────────────────
deploy:
name: Deploy to shared hosting
runs-on: ubuntu-latest
container:
image: node:22-alpine
if: github.ref == 'refs/heads/develop'
needs: [lint, build]
environment:
name: production
url: ${{ vars.PRODUCTION_URL }}
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
- name: Install SSH and rsync
run: |
apk add --no-cache openssh-client sshpass rsync
- name: Deploy built files to shared hosting
run: |
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
-e "ssh -p ${{ vars.SSH_PORT || 22 }} -o StrictHostKeyChecking=no" \
--exclude='.htaccess' \
./dist/ \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ vars.SSH_TARGET_DIR }}/
+63
View File
@@ -0,0 +1,63 @@
stages:
- build
- deploy
variables:
CACHE_KEY: "node-modules-${CI_COMMIT_REF_SLUG}"
BUILD_DIR: "dist"
cache:
key: $CACHE_KEY
paths:
- node_modules/
policy: pull-push
# Build stage
build:
stage: build
image: node:20-alpine
script:
- echo "Installing dependencies..."
- npm ci
- echo "Building project..."
- npm run build
- echo "Build completed!"
artifacts:
paths:
- $BUILD_DIR/
expire_in: 1 hour
only:
- develop
# Deploy to shared hosting via FTP
deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client sshpass rsync
script:
- echo "Starting deployment to $SSH_HOST on port $SSH_PORT..."
- |
if sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
-e "ssh -p $SSH_PORT -o StrictHostKeyChecking=no" \
--exclude='.htaccess' \
--exclude='.env' \
--exclude='.git' \
$BUILD_DIR/ $SSH_USER@$SSH_HOST:$SSH_TARGET_DIR/; then
echo "✅ Deployment successful!"
echo "🌐 Website: $PRODUCTION_URL"
else
echo "❌ Deployment failed!"
exit 1
fi
environment:
name: production
url: $PRODUCTION_URL
only:
- develop
when: manual
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
+2 -2
View File
@@ -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
+2 -2
View File
@@ -12,7 +12,7 @@ services:
- client_node_modules:/app/node_modules - client_node_modules:/app/node_modules
environment: environment:
# Laravel API — Windows/macOS Docker Desktop resolves this; Linux uses extra_hosts below. # Laravel API — Windows/macOS Docker Desktop resolves this; Linux uses extra_hosts below.
VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8080} VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8000}
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
@@ -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'
+12
View File
@@ -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',
},
}, },
]) ])
+561 -550
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "echo \"No unit tests configured yet\" && exit 0",
"test:api": "node scripts/test-api.mjs", "test:api": "node scripts/test-api.mjs",
"gen:view-meta": "node scripts/gen-view-route-meta.mjs", "gen:view-meta": "node scripts/gen-view-route-meta.mjs",
"dev": "vite", "dev": "vite",
+28
View File
@@ -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;
}
+96 -1
View File
@@ -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(() =>
@@ -77,6 +78,26 @@ const ViewRouteSitemapPage = lazy(() =>
const ViewRoutePlaceholderPage = lazy(() => const ViewRoutePlaceholderPage = lazy(() =>
import('./pages/ViewRoutePlaceholderPage').then((m) => ({ default: m.ViewRoutePlaceholderPage })), import('./pages/ViewRoutePlaceholderPage').then((m) => ({ default: m.ViewRoutePlaceholderPage })),
) )
const SchoolYearsManagementPage = lazy(() =>
import('./pages/administrator/SchoolYearsManagementPage').then((m) => ({
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 })),
) )
@@ -274,6 +295,12 @@ const AdministratorAbsencePage = lazy(() =>
const AttendanceTrackingPage = lazy(() => const AttendanceTrackingPage = lazy(() =>
import('./pages/attendance').then((m) => ({ default: m.AttendanceTrackingPage })), import('./pages/attendance').then((m) => ({ default: m.AttendanceTrackingPage })),
) )
const AttendanceManagementPage = lazy(() =>
import('./pages/attendance').then((m) => ({ default: m.AttendanceManagementPage })),
)
const BadgeScanningListPage = lazy(() =>
import('./pages/attendance').then((m) => ({ default: m.BadgeScanningListPage })),
)
const ComposeAttendanceEmailPage = lazy(() => const ComposeAttendanceEmailPage = lazy(() =>
import('./pages/attendance').then((m) => ({ default: m.ComposeAttendanceEmailPage })), import('./pages/attendance').then((m) => ({ default: m.ComposeAttendanceEmailPage })),
) )
@@ -468,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,
@@ -502,6 +543,11 @@ const TeacherPrintRequestsPage = lazy(() =>
const CertificatesPage = lazy(() => const CertificatesPage = lazy(() =>
import('./pages/certificates/CertificatesPage').then((m) => ({ default: m.CertificatesPage })), import('./pages/certificates/CertificatesPage').then((m) => ({ default: m.CertificatesPage })),
) )
const CertificatesAuditLogPage = lazy(() =>
import('./pages/certificates/CertificatesAuditLogPage').then((m) => ({
default: m.CertificatesAuditLogPage,
})),
)
const StickerFormPage = lazy(() => const StickerFormPage = lazy(() =>
import('./pages/printables/StickerFormPage').then((m) => ({ default: m.StickerFormPage })), import('./pages/printables/StickerFormPage').then((m) => ({ default: m.StickerFormPage })),
) )
@@ -616,6 +662,21 @@ const ScorePredictionPage = lazy(() =>
default: m.ScorePredictionPage, default: m.ScorePredictionPage,
})), })),
) )
const TrophyProjectionPage = lazy(() =>
import('./pages/trophy/TrophyPages').then((m) => ({
default: m.TrophyProjectionPage,
})),
)
const TrophyWinnersPage = lazy(() =>
import('./pages/trophy/TrophyPages').then((m) => ({
default: m.TrophyWinnersPage,
})),
)
const TrophyFinalPage = lazy(() =>
import('./pages/trophy/TrophyPages').then((m) => ({
default: m.TrophyFinalPage,
})),
)
const SlipPreviewListPage = lazy(() => const SlipPreviewListPage = lazy(() =>
import('./pages/slips/SlipPreviewListPage').then((m) => ({ default: m.SlipPreviewListPage })), import('./pages/slips/SlipPreviewListPage').then((m) => ({ default: m.SlipPreviewListPage })),
) )
@@ -671,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 })),
@@ -961,6 +1022,7 @@ export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<SchoolYearProvider>
<GlobalTableSorting /> <GlobalTableSorting />
<Suspense fallback={<RouteFallback />}> <Suspense fallback={<RouteFallback />}>
<Routes> <Routes>
@@ -1073,6 +1135,9 @@ export default function App() {
<Route path="administrator/absence" element={<AdministratorAbsencePage />} /> <Route path="administrator/absence" element={<AdministratorAbsencePage />} />
<Route path="administrator/attendance/admins-form" element={<AdminsAttendanceFormPage />} /> <Route path="administrator/attendance/admins-form" element={<AdminsAttendanceFormPage />} />
<Route path="administrator/attendance/tracking" element={<AttendanceTrackingPage />} /> <Route path="administrator/attendance/tracking" element={<AttendanceTrackingPage />} />
<Route path="administrator/attendance/management" element={<AttendanceManagementPage />} />
<Route path="administrator/attendance/badge-scans" element={<BadgeScanningListPage />} />
<Route path="attendance/management" element={<AttendanceManagementPage />} />
<Route path="administrator/attendance/compose-email" element={<ComposeAttendanceEmailPage />} /> <Route path="administrator/attendance/compose-email" element={<ComposeAttendanceEmailPage />} />
<Route path="administrator/attendance/early-dismissals" element={<EarlyDismissalsPage />} /> <Route path="administrator/attendance/early-dismissals" element={<EarlyDismissalsPage />} />
<Route path="administrator/attendance/early-dismissals/new" element={<EarlyDismissalsAddPage />} /> <Route path="administrator/attendance/early-dismissals/new" element={<EarlyDismissalsAddPage />} />
@@ -1097,6 +1162,18 @@ export default function App() {
<Route path="administrator/communications" element={<CommunicationsIndexPage />} /> <Route path="administrator/communications" element={<CommunicationsIndexPage />} />
<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="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 />} />
@@ -1169,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 />} />
@@ -1210,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"
@@ -1259,10 +1347,16 @@ export default function App() {
element={<ReportCardManagementPage />} element={<ReportCardManagementPage />}
/> />
<Route path="administrator/certificates" element={<CertificatesPage />} /> <Route path="administrator/certificates" element={<CertificatesPage />} />
<Route path="administrator/certificates/log" element={<CertificatesAuditLogPage />} />
<Route <Route
path="administrator/score-analysis/score-prediction" path="administrator/score-analysis/score-prediction"
element={<ScorePredictionPage />} element={<ScorePredictionPage />}
/> />
<Route path="administrator/trophy" element={<TrophyProjectionPage />} />
<Route path="administrator/trophy/winners" element={<TrophyWinnersPage />} />
<Route path="administrator/trophy/final" element={<TrophyFinalPage />} />
<Route path="administrator/trophy_winners" element={<TrophyWinnersPage />} />
<Route path="administrator/trophy_final" element={<TrophyFinalPage />} />
<Route path="administrator/slips/preview" element={<SlipPreviewListPage />} /> <Route path="administrator/slips/preview" element={<SlipPreviewListPage />} />
<Route path="administrator/slips/print" element={<LateSlipPrintPage />} /> <Route path="administrator/slips/print" element={<LateSlipPrintPage />} />
<Route path="slips/print" element={<LateSlipPrintPage />} /> <Route path="slips/print" element={<LateSlipPrintPage />} />
@@ -1472,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>
) )
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../../alrahma_sunday_school_api"
},
{
"path": "../.."
}
],
"settings": {}
}
+146
View File
@@ -0,0 +1,146 @@
import { apiFetch } from './http'
const BASE = '/api/v1/attendance/management'
type ApiEnvelope<T> = { status?: boolean; message?: string; data?: T }
function unwrap<T>(body: ApiEnvelope<T> | T): T {
if (body && typeof body === 'object' && 'data' in body) return (body as ApiEnvelope<T>).data as T
return body as T
}
export type AttendanceManagementSummary = {
present: number
absent: number
late: number
early_dismissal: number
not_reported: number
follow_up_required: number
badge_exceptions: number
late_slip_reprints: number
}
export type AttendanceManagementEvent = {
id: number
person_type: string
person_id: number | null
person_name: string | null
role_grade: string | null
badge_id: string | null
event_date: string
attendance_status: string
report_status: string
entry_time: string | null
exit_time: string | null
official_entry_time: string | null
official_exit_time: string | null
entry_method: string | null
exit_method: string | null
manual_reason: string | null
reason: string | null
scan_location: string | null
exit_location: string | null
absence_count: number
late_count: number
early_dismissal_count: number
badge_exception_count: number
combination_code: string | null
risk_level: string
follow_up_required: boolean | number
follow_up_completed: boolean | number
action_needed: string | null
final_decision: string | null
notes: string | null
}
export type AttendanceManagementDashboard = {
date: string
summary: AttendanceManagementSummary
filters: {
attendance_status: string[]
report_status: string[]
risk_level: string[]
person_type: string[]
}
rows: AttendanceManagementEvent[]
}
export type ManualEntryPayload = {
person_type?: string
person_id?: number
person_name?: string
role_grade?: string
badge_id?: string
entry_time?: string
manual_reason?: string
report_status?: string
reason?: string
notes?: string
}
export type ScanPayload = {
badge_scan?: string
badge_id?: string
scan_type?: 'entry' | 'exit'
scan_time?: string
location?: string
report_status?: string
authorized?: boolean
reason?: string
}
export async function fetchAttendanceManagementDashboard(params: Record<string, string> = {}) {
const qs = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v.trim()) qs.set(k, v.trim())
})
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return unwrap<AttendanceManagementDashboard>(await apiFetch(`${BASE}/dashboard${suffix}`))
}
export async function fetchBadgeScanningList(params: Record<string, string> = {}) {
return fetchAttendanceManagementDashboard({
...params,
entry_method: 'badge_scan',
})
}
export async function recordManualAttendanceEntry(payload: ManualEntryPayload) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/manual-entry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function recordAttendanceBadgeScan(payload: ScanPayload) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function completeAttendanceFollowUp(id: number, payload: { report_status?: string; final_decision?: string; notes?: string }) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/${id}/follow-up`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function reprintAttendanceLateSlip(id: number, payload: { reason?: string; slip_number?: string } = {}) {
return unwrap<{ reprint: Record<string, unknown> }>(
await apiFetch(`${BASE}/${id}/late-slip-reprint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
+124 -33
View File
@@ -1,65 +1,156 @@
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 CertClassSection = { export type CertificateDecisionRow = {
class_section_id: number decision: string
class_section_name: string source: string
notes: string
year_score: number | null
} }
export type CertStudent = { export type CertificateStudentRow = {
id: number student_id: number
firstname: string firstname: string
lastname: string lastname: string
grade: string class_section_id: number
class_section_name: string
year_score: number | null
eligible: boolean
decision_state: 'pending' | 'pass' | 'decision'
decision_labels: string[]
decision_rows: CertificateDecisionRow[]
certificate_number: string | null
} }
export type CertFormOptions = { export type CertificateSectionRow = {
class_sections: CertClassSection[] section_id: number
students: CertStudent[] section_name: string
student_count: number
pass_count: number
cert_count: number
remaining_count: number
students: CertificateStudentRow[]
}
export type CertificateGradeGroup = {
key: string
label: string
slug: string
total: number
pass: number
cert: number
fully_done: boolean
has_pass: boolean
sections: CertificateSectionRow[]
}
export type CertificateDashboardPayload = {
school_year: string school_year: string
selected_class_id: string | null cert_date: string
grade_groups: CertificateGradeGroup[]
stats_per_class: Record<string, { name: string; total: number; pass: number; cert: number }>
default_group_key: string | null
} }
export async function fetchCertFormOptions(params?: { export type CertificateAuditRecord = {
school_year?: string certificate_number: string
class_section_id?: string | number student_name: string
}): Promise<ApiEnvelope<CertFormOptions>> { grade?: string | null
cert_date?: string | null
school_year?: string | null
admin_firstname?: string | null
admin_lastname?: string | null
issued_at?: string | null
}
export type CertificateAuditPayload = {
school_year: string
records: CertificateAuditRecord[]
year_summary: Array<{ school_year: string; total: number }>
}
function authHeaders(accept: string) {
const headers = new Headers({ Accept: accept })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
return headers
}
function withQuery(path: string, params?: Record<string, string | number | null | undefined>) {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year) Object.entries(params ?? {}).forEach(([key, value]) => {
if (params?.class_section_id != null && String(params.class_section_id) !== '') if (value !== undefined && value !== null && String(value).trim() !== '') {
qs.set('class_section_id', String(params.class_section_id)) qs.set(key, String(value))
const suffix = qs.toString() ? `?${qs}` : '' }
return apiFetch<ApiEnvelope<CertFormOptions>>(`/api/v1/administrator/certificates/form-options${suffix}`) })
return qs.size > 0 ? `${path}?${qs.toString()}` : path
}
export async function fetchCertificatesDashboard(params?: {
school_year?: string
}): Promise<CertificateDashboardPayload> {
const res = await apiFetch<ApiEnvelope<CertificateDashboardPayload>>(
withQuery('/api/v1/administrator/certificates/dashboard', params),
)
return (res.data ?? {}) as CertificateDashboardPayload
}
export async function fetchCertificatesAuditLog(params?: {
school_year?: string
}): Promise<CertificateAuditPayload> {
const res = await apiFetch<ApiEnvelope<CertificateAuditPayload>>(
withQuery('/api/v1/administrator/certificates/audit-log', params),
)
return (res.data ?? {}) as CertificateAuditPayload
} }
export async function postCertificateGenerate(payload: { export async function postCertificateGenerate(payload: {
student_ids: number[] student_ids: number[]
cert_date: string cert_date: string
class_section_id?: number | string | null class_section_id?: number | null
school_year?: string | null
}): Promise<Blob> { }): Promise<Blob> {
const headers = new Headers({
Accept: 'application/pdf,*/*',
'Content-Type': 'application/json',
})
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiUrl('/api/v1/administrator/certificates/generate'), { const res = await fetch(apiUrl('/api/v1/administrator/certificates/generate'), {
method: 'POST', method: 'POST',
headers, headers: (() => {
const headers = authHeaders('application/pdf,*/*')
headers.set('Content-Type', 'application/json')
return headers
})(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
if (!res.ok) { if (!res.ok) {
const errBody: unknown = await res.json().catch(() => null) const errBody: unknown = await res.json().catch(() => null)
const msg = const message =
typeof errBody === 'object' && errBody !== null && 'message' in errBody typeof errBody === 'object' && errBody !== null && 'message' in errBody
? String((errBody as { message?: unknown }).message ?? '') ? String((errBody as { message?: unknown }).message ?? '')
: '' : ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody) throw new ApiHttpError(message || `HTTP ${res.status}`, res.status, errBody)
} }
const raw = await res.blob() return res.blob()
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' }) }
export async function fetchCertificateReprint(certificateNumber: string): Promise<Blob> {
const res = await fetch(
apiUrl(`/api/v1/administrator/certificates/reprint/${encodeURIComponent(certificateNumber)}`),
{
method: 'GET',
headers: authHeaders('application/pdf,*/*'),
},
)
if (!res.ok) {
const errBody: unknown = await res.json().catch(() => null)
const message =
typeof errBody === 'object' && errBody !== null && 'message' in errBody
? String((errBody as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(message || `HTTP ${res.status}`, res.status, errBody)
}
return res.blob()
} }
+5 -5
View File
@@ -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),
+54 -4
View File
@@ -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'
@@ -11,6 +12,37 @@ export function setStoredToken(token: string | null): void {
else localStorage.removeItem(TOKEN_KEY) else localStorage.removeItem(TOKEN_KEY)
} }
export function decodeJwtPayload<T = unknown>(token: string): T | null {
try {
const payload = token.split('.')[1]
if (!payload) return null
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const json = decodeURIComponent(
atob(base64)
.split('')
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
.join(''),
)
return JSON.parse(json) as T
} catch {
return 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
@@ -30,24 +62,41 @@ export async function apiFetch<T>(
): Promise<T> { ): Promise<T> {
const attachAuth = opts.attachAuth !== false const attachAuth = opts.attachAuth !== false
const headers = new Headers(init.headers) const headers = new Headers(init.headers)
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json')
}
const token = getStoredToken() const token = getStoredToken()
if (attachAuth && token && !headers.has('Authorization')) { if (attachAuth && token && !headers.has('Authorization')) {
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
try { try {
res = await fetch(url, { ...init, headers }) res = await fetch(url, {
...init,
headers,
})
} 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 (see vite.config.ts; default proxy is http://localhost:8080 unless VITE_PROXY_API 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'
throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null) throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null)
} }
const body: unknown = await res.json().catch(() => null) const body: unknown = await res.json().catch(() => null)
if (!res.ok) { if (!res.ok) {
@@ -55,6 +104,7 @@ export async function apiFetch<T>(
typeof body === 'object' && body !== null && 'message' in body typeof body === 'object' && body !== null && 'message' in body
? String((body as { message?: unknown }).message ?? '') ? String((body as { message?: unknown }).message ?? '')
: '' : ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body) throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
} }
+78 -12
View File
@@ -4,13 +4,20 @@
*/ */
import { apiFetch } from './http' import { apiFetch } from './http'
const BASE = '/api/v1/administrator/inventory' const BASE = '/api/v1/inventory'
function q(searchParams: URLSearchParams): string { function q(searchParams: URLSearchParams): string {
const s = searchParams.toString() const s = searchParams.toString()
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). */
@@ -18,11 +25,13 @@ export async function fetchInventoryIndex(
kind: InventoryKind, kind: InventoryKind,
searchParams: URLSearchParams, searchParams: URLSearchParams,
): Promise<unknown> { ): Promise<unknown> {
return apiFetch(`${BASE}/${kind}${q(searchParams)}`) const params = new URLSearchParams(searchParams)
params.set('type', kind)
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),
@@ -35,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). */
@@ -43,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> {
@@ -59,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),
}) })
@@ -84,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),
@@ -92,11 +103,15 @@ export async function postClassroomAudit(
} }
export async function fetchInventorySummary(searchParams: URLSearchParams): Promise<unknown> { export async function fetchInventorySummary(searchParams: URLSearchParams): Promise<unknown> {
return apiFetch(`${BASE}/summary${q(searchParams)}`) const type = searchParams.get('type') || 'classroom'
const params = new URLSearchParams(searchParams)
params.delete('type')
const qs = params.toString()
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(
@@ -104,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> {
@@ -122,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),
}) })
@@ -156,3 +171,54 @@ export async function postTeacherBookDistribute(body: Record<string, unknown>):
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
/* ─── New Inventory Improvement API Calls ─── */
/** Fetch low-stock items (quantity <= reorder_point). */
export async function fetchLowStock(): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/low-stock`))
}
/** Fetch reorder suggestions for low-stock items. */
export async function fetchReorderSuggestions(): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/reorder-suggestions`))
}
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
export async function fetchStockStatus(
searchParams: URLSearchParams,
): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/stock-status${q(searchParams)}`))
}
/** Fetch inventory dashboard summary counts. */
export async function fetchInventoryDashboard(
searchParams: URLSearchParams,
): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/dashboard${q(searchParams)}`))
}
/** Void a movement (append-only correction with reverse entry). */
export async function voidMovement(
movementId: string | number,
reason: string,
): Promise<unknown> {
return apiFetch(`${BASE}/movements/${movementId}/void`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
})
}
/** Create a correction movement linked to an existing movement. */
export async function correctMovement(
movementId: string | number,
qtyChange: number,
reason: string,
): Promise<unknown> {
return apiFetch(`${BASE}/movements/${movementId}/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qty_change: qtyChange, reason }),
})
}
+8 -9
View File
@@ -2,9 +2,9 @@
* 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/administrator/invoices' const BASE = '/api/v1/finance/invoices'
function q(searchParams: URLSearchParams): string { function q(searchParams: URLSearchParams): string {
const s = searchParams.toString() const s = searchParams.toString()
@@ -35,7 +35,7 @@ export async function fetchInvoiceManagement(
} }
export async function postInvoiceGenerate(parentId: number): Promise<unknown> { export async function postInvoiceGenerate(parentId: number): Promise<unknown> {
return apiFetch(`${BASE}/generate-for-parent`, { return apiFetch(`${BASE}/generate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_id: parentId }), body: JSON.stringify({ parent_id: parentId }),
@@ -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}`)
+70
View File
@@ -0,0 +1,70 @@
import { apiFetch } from './http'
export type ParentProfileSummary = {
id: number
firstname?: string | null
lastname?: string | null
email?: string | null
cellphone?: string | null
is_active?: boolean
families_count?: number
selected_year_students_count?: number
balance?: number
positive_unpaid_balance?: number
credit_balance?: number
primary_family?: { id?: number; household_name?: string | null } | null
}
export type ParentProfileDetail = {
school_year: string
read_only: boolean
parent: Record<string, unknown> | null
families: Record<string, unknown>[]
students: Record<string, unknown>[]
emergency_contacts: Record<string, unknown>[]
invoices: Record<string, unknown>[]
payments: Record<string, unknown>[]
finance_summary: Record<string, unknown>
}
type Envelope<T> = { data?: T }
function unwrap<T>(response: T | Envelope<T>): T {
if (response && typeof response === 'object' && 'data' in response) {
return (response as Envelope<T>).data as T
}
return response as T
}
export async function fetchParentProfiles(params: {
schoolYear: string
q?: string
page?: number
perPage?: number
}): Promise<{
school_year: string
read_only: boolean
parents: ParentProfileSummary[]
pagination: { page: number; per_page: number; total: number }
}> {
const query = new URLSearchParams()
query.set('school_year', params.schoolYear)
if (params.q?.trim()) query.set('q', params.q.trim())
if (params.page) query.set('page', String(params.page))
if (params.perPage) query.set('per_page', String(params.perPage))
return unwrap(await apiFetch(`/api/v1/parent-profiles?${query.toString()}`))
}
export async function fetchParentProfile(parentId: number, schoolYear: string): Promise<ParentProfileDetail> {
const query = new URLSearchParams({ school_year: schoolYear })
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`))
}
export async function updateParentProfile(parentId: number, schoolYear: string, payload: Record<string, unknown>): Promise<ParentProfileDetail> {
const query = new URLSearchParams({ school_year: schoolYear })
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, school_year: schoolYear }),
}))
}
+61 -6
View File
@@ -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()
@@ -175,7 +177,17 @@ export async function fetchUnpaidParents(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/payments/unpaid-parents${suffix}`) return apiFetch<{
rows?: UnpaidParentRow[]
results?: UnpaidParentRow[]
school_year?: string
schoolYear?: string
schoolYears?: string[]
}>(`/api/v1/finance/unpaid-parents${suffix}`).then((body) => ({
rows: body.rows ?? body.results ?? [],
school_year: body.school_year ?? body.schoolYear,
schoolYears: body.schoolYears ?? [],
}))
} }
export async function sendPaymentReminders(payload: { export async function sendPaymentReminders(payload: {
@@ -287,6 +299,15 @@ export type FinancialDetailedJson = {
eventFeesTotal?: number eventFeesTotal?: number
} }
function unwrapFinancialReport(
body: FinancialDetailedJson & { report?: FinancialDetailedJson },
): FinancialDetailedJson {
if (body && typeof body === 'object' && body.report && typeof body.report === 'object') {
return body.report
}
return body
}
export async function fetchFinancialReportDetailed(params: { export async function fetchFinancialReportDetailed(params: {
school_year?: string school_year?: string
date_from?: string date_from?: string
@@ -297,7 +318,10 @@ export async function fetchFinancialReportDetailed(params: {
if (params.date_from) qs.set('date_from', params.date_from) if (params.date_from) qs.set('date_from', params.date_from)
if (params.date_to) qs.set('date_to', params.date_to) if (params.date_to) qs.set('date_to', params.date_to)
qs.set('format', 'json') qs.set('format', 'json')
return apiFetch(`/api/v1/administrator/reports/financial-detailed?${qs}`) const body = await apiFetch<FinancialDetailedJson & { report?: FinancialDetailedJson }>(
`/api/v1/finance/financial-report?${qs}`,
)
return unwrapFinancialReport(body)
} }
export async function downloadFinancialReportCsv(params: { export async function downloadFinancialReportCsv(params: {
@@ -309,10 +333,11 @@ export async function downloadFinancialReportCsv(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.date_from) qs.set('date_from', params.date_from) if (params.date_from) qs.set('date_from', params.date_from)
if (params.date_to) qs.set('date_to', params.date_to) if (params.date_to) qs.set('date_to', params.date_to)
const path = `/api/v1/administrator/reports/financial-detailed.csv?${qs}` const path = `/api/v1/finance/financial-report/csv?${qs}`
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()
@@ -341,22 +366,52 @@ export type FinancialSummaryJson = {
totalPaid?: number totalPaid?: number
} }
function unwrapFinancialSummary(
body: FinancialSummaryJson & {
summary?: FinancialSummaryJson
schoolYears?: string[]
},
): FinancialSummaryJson {
const summary =
body && typeof body === 'object' && body.summary && typeof body.summary === 'object'
? body.summary
: body
if (
body &&
typeof body === 'object' &&
Array.isArray(body.schoolYears) &&
!Array.isArray((summary as { schoolYears?: unknown }).schoolYears)
) {
return {
...summary,
schoolYears: body.schoolYears,
} as FinancialSummaryJson
}
return summary
}
export async function fetchFinancialReportSummary(params: { export async function fetchFinancialReportSummary(params: {
school_year?: string school_year?: string
}): Promise<FinancialSummaryJson> { }): Promise<FinancialSummaryJson> {
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)
qs.set('format', 'json') qs.set('format', 'json')
return apiFetch(`/api/v1/administrator/reports/financial-summary?${qs}`) const body = await apiFetch<
FinancialSummaryJson & { summary?: FinancialSummaryJson; schoolYears?: string[] }
>(`/api/v1/finance/financial-summary?${qs}`)
return unwrapFinancialSummary(body)
} }
export async function downloadFinancialSummaryPdf(params: { school_year?: string }): Promise<void> { export async function downloadFinancialSummaryPdf(params: { school_year?: string }): Promise<void> {
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)
const path = `/api/v1/administrator/reports/financial-summary.pdf?${qs}` const path = `/api/v1/finance/financial-report/pdf?${qs}`
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()
+13 -4
View File
@@ -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
View File
@@ -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 -3
View File
@@ -1,10 +1,10 @@
/** /**
* Admin refunds list (legacy CI `refunds/list.php`). * Admin refunds list (legacy CI `refunds/list.php`).
* Laravel: `GET /api/v1/administrator/refunds/list` with JWT. * Laravel: `GET /api/v1/finance/refunds` with JWT.
*/ */
import { apiFetch } from './http' import { apiFetch } from './http'
const BASE = '/api/v1/administrator/refunds' const BASE = '/api/v1/finance/refunds'
export type RefundListRow = Record<string, unknown> export type RefundListRow = Record<string, unknown>
@@ -42,6 +42,6 @@ export async function fetchRefundsList(params?: {
if (params?.semester) qs.set('semester', params.semester) if (params?.semester) qs.set('semester', params.semester)
if (params?.page != null) qs.set('page', String(params.page)) if (params?.page != null) qs.set('page', String(params.page))
const suffix = qs.toString() ? `?${qs}` : '' const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<unknown>(`${BASE}/list${suffix}`) const body = await apiFetch<unknown>(`${BASE}${suffix}`)
return normalizeListPayload(body) return normalizeListPayload(body)
} }
+12 -7
View File
@@ -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) {
+459
View File
@@ -0,0 +1,459 @@
import { apiFetch } from './http'
export type SchoolYearStatus = 'draft' | 'active' | 'closed' | 'archived' | string
export type SchoolYearRecord = {
id: number
name: string
start_date: string
end_date: string
status: SchoolYearStatus
is_current: boolean
isCurrent?: boolean
closed_at?: string | null
closed_by?: number | null
}
export type SchoolYearSummary = {
school_year: SchoolYearRecord
totals: {
students_considered: number
students_blocked: number
parents_with_balances: number
parents_with_positive_balance?: number
net_balance_to_transfer: number
net_balance_impact?: number
total_positive_unpaid_balance?: number
total_credit_balance?: number
}
}
export type SchoolYearPromotionRow = {
student_id: number
student_name: string
parent_id?: number | null
current_grade?: string | null
current_class?: string | null
promotion_action: string
target_grade?: string | null
target_class?: string | null
target_class_section_id?: number | null
warnings?: string[]
decision?: string | null
score?: number | null
}
export type SchoolYearPromotionPreview = {
rows: SchoolYearPromotionRow[]
summary: {
total_students: number
promote: number
repeat: number
graduate: number
transfer: number
withdraw: number
hold: number
}
}
export type SchoolYearParentBalanceRow = {
parent_id: number
parent_name?: string | null
student_names?: string[]
old_unpaid_balance: number
positive_unpaid_balance?: number
credit_balance: number
net_balance?: number
amount_to_transfer: number
positive_invoice_ids?: number[]
credit_invoice_ids?: number[]
source_invoice_ids?: number[]
existing_transfer_conflict?: boolean
}
export type SchoolYearParentBalancePreview = {
from_school_year?: string
to_school_year?: string
rows: SchoolYearParentBalanceRow[]
summary: {
parents_with_non_zero_balance: number
parents_with_positive_balance?: number
parents_with_credit_balance: number
total_old_unpaid_balance: number
total_positive_unpaid_balance?: number
total_credit_balance: number
net_balance_to_transfer: number
net_balance_impact?: number
existing_transfer_conflicts: number
}
}
export type SchoolYearCloseValidation = {
can_close: boolean
errors: string[]
warnings: string[]
promotion_summary: SchoolYearPromotionPreview['summary']
financial_summary: SchoolYearParentBalancePreview['summary']
}
export type SchoolYearClosePreview = {
school_year: SchoolYearRecord
validation: SchoolYearCloseValidation
promotion_preview: SchoolYearPromotionPreview
parent_balances: SchoolYearParentBalancePreview
}
export type SchoolYearCloseResult = {
closed_school_year: SchoolYearRecord
new_school_year: SchoolYearRecord
promotion_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 = {
name: string
start_date: string
end_date: string
}
export type CloseSchoolYearPayload = {
new_school_year: SaveSchoolYearPayload
promotion_rules?: {
default_action?: string
exceptions?: Array<{
student_id: number
action: string
target_grade_id?: number | null
target_class_section_id?: number | null
}>
}
transfer_unpaid_balances?: boolean
confirmation?: string
}
type ApiListResponse = {
ok?: boolean
data?: SchoolYearRecord[]
}
type ApiRecordResponse = {
ok?: boolean
data?: SchoolYearRecord
}
type ApiSummaryResponse = {
ok?: boolean
data?: SchoolYearSummary
}
type ApiValidationResponse = {
ok?: boolean
validation?: SchoolYearCloseValidation
}
type ApiPreviewResponse = {
ok?: boolean
data?: SchoolYearClosePreview
}
type ApiParentBalancesResponse = {
ok?: boolean
data?: SchoolYearParentBalancePreview
}
type ApiPromotionPreviewResponse = {
ok?: boolean
data?: SchoolYearPromotionPreview
}
type ApiCloseResponse = {
ok?: boolean
data?: SchoolYearCloseResult
}
type ApiClosingReportResponse = {
ok?: boolean
data?: SchoolYearClosingReport
}
type ApiBalanceTransferReportResponse = {
ok?: boolean
data?: SchoolYearBalanceTransferReport
}
type ApiParentStatementResponse = {
ok?: boolean
data?: ParentStatement
}
export async function fetchSchoolYears(): Promise<SchoolYearRecord[]> {
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
return response.data ?? []
}
export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>('/api/v1/school-years', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.data) {
throw new Error('School year creation returned no record.')
}
return response.data
}
export async function updateSchoolYear(
schoolYear: string,
payload: SaveSchoolYearPayload,
): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(
`/api/v1/school-years/selected${selectedYearQuery(schoolYear)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.data) {
throw new Error('School year update returned no record.')
}
return response.data
}
function selectedYearQuery(schoolYear: string, extras?: Record<string, string | undefined>): string {
const query = new URLSearchParams()
query.set('school_year', schoolYear)
for (const [key, value] of Object.entries(extras ?? {})) {
if (value) query.set(key, value)
}
return `?${query.toString()}`
}
function selectedYearBody(payload: object, schoolYear: string): string {
return JSON.stringify({ ...payload, school_year: schoolYear })
}
export async function fetchSchoolYearSummary(schoolYear: string): Promise<SchoolYearSummary> {
const response = await apiFetch<ApiSummaryResponse>(
`/api/v1/school-years/summary${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year summary returned no data.')
}
return response.data
}
export async function validateSchoolYearClose(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseValidation> {
const response = await apiFetch<ApiValidationResponse>(
`/api/v1/school-years/validate-close${selectedYearQuery(schoolYear)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.validation) {
throw new Error('School year validation returned no payload.')
}
return response.validation
}
export async function previewSchoolYearClose(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearClosePreview> {
const response = await apiFetch<ApiPreviewResponse>(
`/api/v1/school-years/preview-close${selectedYearQuery(schoolYear)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.data) {
throw new Error('School year close preview returned no payload.')
}
return response.data
}
export async function closeSchoolYear(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseResult> {
const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/close${selectedYearQuery(schoolYear)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
})
if (!response.data) {
throw new Error('School year close returned no payload.')
}
return response.data
}
export async function reopenSchoolYear(schoolYear: string): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/reopen${selectedYearQuery(schoolYear)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ school_year: schoolYear }),
})
if (!response.data) {
throw new Error('School year reopen returned no payload.')
}
return response.data
}
export async function fetchSchoolYearParentBalances(
schoolYear: string,
toSchoolYear?: string,
): Promise<SchoolYearParentBalancePreview> {
const response = await apiFetch<ApiParentBalancesResponse>(
`/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
)
if (!response.data) {
throw new Error('Parent balances preview returned no payload.')
}
return response.data
}
export async function fetchSchoolYearPromotionPreview(
schoolYear: string,
toSchoolYear?: string,
): Promise<SchoolYearPromotionPreview> {
const response = await apiFetch<ApiPromotionPreviewResponse>(
`/api/v1/school-years/promotion-preview${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
)
if (!response.data) {
throw new Error('Promotion preview returned no payload.')
}
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
}
+397 -36
View File
@@ -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,
@@ -23,7 +23,6 @@ import type {
GradingOverviewResponse, GradingOverviewResponse,
IpBansResponse, IpBansResponse,
LandingPageResponse, LandingPageResponse,
LoginFailure,
LoginResponse, LoginResponse,
LoginSuccess, LoginSuccess,
LateSlipLogsResponse, LateSlipLogsResponse,
@@ -33,6 +32,7 @@ import type {
NavMenuResponse, NavMenuResponse,
ParentAttendanceResponse, ParentAttendanceResponse,
ParentAttendanceReportFormResponse, ParentAttendanceReportFormResponse,
ParentEnrollmentActionResponse,
ParentEnrollmentsResponse, ParentEnrollmentsResponse,
ParentEventsOverviewResponse, ParentEventsOverviewResponse,
ParentInvoicesResponse, ParentInvoicesResponse,
@@ -60,6 +60,13 @@ import type {
ParentProfileRecord, ParentProfileRecord,
ContactSubmitResponse, ContactSubmitResponse,
CompetitionScoresEditResponse, CompetitionScoresEditResponse,
CompetitionWinnerAdminActionResponse,
CompetitionWinnerAdminFormResponse,
CompetitionWinnerAdminIndexResponse,
CompetitionWinnerAdminPreviewResponse,
CompetitionWinnerAdminScoresResponse,
CompetitionWinnerAdminWinnersResponse,
CompetitionWinnerExportQuizResponse,
CompetitionScoresIndexResponse, CompetitionScoresIndexResponse,
ExamDraftAdminResponse, ExamDraftAdminResponse,
PublicCompetitionIndexResponse, PublicCompetitionIndexResponse,
@@ -89,36 +96,144 @@ import type {
EnrollmentWithdrawalPageResponse, EnrollmentWithdrawalPageResponse,
ExpensesListResponse, ExpensesListResponse,
ExpenseCreateFormResponse, ExpenseCreateFormResponse,
ExpenseDetail,
ExpenseEditFormResponse, ExpenseEditFormResponse,
ExpenseUserOption,
FamilyLegacyImportMetaResponse, FamilyLegacyImportMetaResponse,
FamilyLegacyImportResult, FamilyLegacyImportResult,
FamilySearchResponse, FamilySearchResponse,
} from './types' } from './types'
type LoginApiObject = Record<string, unknown>
function isObject(value: unknown): value is LoginApiObject {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function stringValue(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
}
function numberValue(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
return Number(value)
}
return 0
}
function rolesToMap(value: unknown): Record<string, boolean> {
if (Array.isArray(value)) {
return Object.fromEntries(
value
.map((role) => String(role ?? '').trim())
.filter(Boolean)
.map((role) => [role, true]),
)
}
if (isObject(value)) {
return Object.fromEntries(
Object.entries(value)
.filter(([role, enabled]) => role.trim() !== '' && Boolean(enabled))
.map(([role]) => [role, true]),
)
}
return {}
}
function normalizeLoginSuccess(body: unknown): LoginSuccess | null {
if (!isObject(body)) return null
const data = isObject(body.data) ? body.data : null
const jwt = data && isObject(data.jwt) ? data.jwt : null
const sanctum = data && isObject(data.sanctum) ? data.sanctum : null
const token =
stringValue(body.token) ??
stringValue(body.access_token) ??
(data ? stringValue(data.token) : null) ??
(data ? stringValue(data.access_token) : null) ??
(jwt ? stringValue(jwt.access_token) : null) ??
(sanctum ? stringValue(sanctum.access_token) : null)
const rawUser =
(isObject(body.user) ? body.user : null) ??
(data && isObject(data.user) ? data.user : null)
if (!token || !rawUser) return null
const firstName = stringValue(rawUser.firstname) ?? stringValue(rawUser.first_name) ?? ''
const lastName = stringValue(rawUser.lastname) ?? stringValue(rawUser.last_name) ?? ''
const name =
stringValue(rawUser.name) ??
[firstName, lastName].filter(Boolean).join(' ').trim() ??
stringValue(rawUser.email) ??
'User'
return {
status: true,
token,
user: {
id: numberValue(rawUser.id),
name,
roles: rolesToMap(rawUser.roles),
class_section_id:
rawUser.class_section_id === null || rawUser.class_section_id === undefined
? null
: numberValue(rawUser.class_section_id),
class_section_name: stringValue(rawUser.class_section_name),
},
}
}
export async function loginRequest( export async function loginRequest(
email: string, email: string,
password: string, password: string,
): Promise<LoginResponse> { ): Promise<LoginResponse> {
const body = await apiFetch<unknown>( const loginPaths = ['/api/v1/auth/login', '/api/v1/login', '/api/login']
'/api/v1/auth/login', let body: unknown = null
{ let lastError: unknown = null
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
},
{ attachAuth: false },
)
if (!body || typeof body !== 'object' || !('status' in body)) { for (const path of loginPaths) {
throw new Error('Invalid login response from API.') try {
body = await apiFetch<unknown>(
path,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
},
{ attachAuth: false },
)
lastError = null
break
} catch (error) {
lastError = error
if (!(error instanceof ApiHttpError) || error.status !== 404) {
throw error
}
}
} }
if ((body as { status?: boolean }).status === false) { if (lastError) throw lastError
const fail = body as LoginFailure
return { status: false, message: fail.message } if (isObject(body)) {
const explicitFailure =
body.status === false ||
body.success === false ||
String(body.result ?? '').toLowerCase() === 'false'
if (explicitFailure) {
return { status: false, message: stringValue(body.message) ?? 'Login failed.' }
}
} }
return body as LoginSuccess const normalized = normalizeLoginSuccess(body)
if (normalized) return normalized
throw new Error('Invalid login response from API. Expected a token and user object.')
} }
export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayload>> { export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayload>> {
@@ -381,7 +496,8 @@ export async function fetchStaffMonthlyAttendanceOverview(params?: {
if (params?.semester) query.set('semester', params.semester) if (params?.semester) query.set('semester', params.semester)
if (params?.schoolYear) query.set('school_year', params.schoolYear) if (params?.schoolYear) query.set('school_year', params.schoolYear)
const qs = query.size > 0 ? `?${query.toString()}` : '' const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<StaffMonthlyOverviewPayload>(`/api/v1/attendance/staff/month${qs}`) const raw = await apiFetch<unknown>(`/api/v1/attendance/staff/month${qs}`)
return unwrapApiDataLayer(raw) as StaffMonthlyOverviewPayload
} }
/** Matches CI `teacher_attendance_month` save cell (form body). */ /** Matches CI `teacher_attendance_month` save cell (form body). */
@@ -392,7 +508,7 @@ export async function saveStaffMonthlyAttendanceCell(payload: Record<string, str
}> { }> {
const body = new URLSearchParams() const body = new URLSearchParams()
for (const [k, v] of Object.entries(payload)) body.set(k, v) for (const [k, v] of Object.entries(payload)) body.set(k, v)
return apiFetch('/api/v1/attendance/staff/month-save', { return apiFetch('/api/v1/attendance/staff/cell', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(), body: body.toString(),
@@ -480,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,
@@ -884,6 +1001,98 @@ export async function fetchCompetitionScoresDetail(
return apiFetch<CompetitionScoresEditResponse>(`/api/v1/competition-scores/${id}${query}`) return apiFetch<CompetitionScoresEditResponse>(`/api/v1/competition-scores/${id}${query}`)
} }
export async function fetchAdminCompetitionWinnersIndex(): Promise<CompetitionWinnerAdminIndexResponse> {
return apiFetch<CompetitionWinnerAdminIndexResponse>('/api/v1/competition-winners')
}
export async function fetchAdminCompetitionWinnerCreate(): Promise<CompetitionWinnerAdminFormResponse> {
return apiFetch<CompetitionWinnerAdminFormResponse>('/api/v1/competition-winners/create')
}
export async function fetchAdminCompetitionWinnerSettings(id: number): Promise<CompetitionWinnerAdminFormResponse> {
return apiFetch<CompetitionWinnerAdminFormResponse>(`/api/v1/competition-winners/${id}/settings`)
}
export async function saveAdminCompetitionWinner(
payload: {
title: string
class_section_id?: number | null
semester?: string | null
school_year?: string | null
start_date?: string | null
end_date?: string | null
winner_overrides?: Record<string, number | string | null>
question_counts?: Record<string, number | string | null>
prizes?: Record<string, Record<string, number | string | null>>
},
id?: number,
): Promise<CompetitionWinnerAdminActionResponse> {
return apiFetch<CompetitionWinnerAdminActionResponse>(id ? `/api/v1/competition-winners/${id}` : '/api/v1/competition-winners', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function fetchAdminCompetitionWinnerScores(
id: number,
classSectionId?: number | null,
): Promise<CompetitionWinnerAdminScoresResponse> {
const query = classSectionId ? `?class_section_id=${encodeURIComponent(String(classSectionId))}` : ''
return apiFetch<CompetitionWinnerAdminScoresResponse>(`/api/v1/competition-winners/${id}/scores${query}`)
}
export async function saveAdminCompetitionWinnerScores(
id: number,
payload: { class_section_id?: number | null; scores: Record<string, number | string> },
): Promise<{ ok: boolean; message?: string; savedCount?: number }> {
return apiFetch<{ ok: boolean; message?: string; savedCount?: number }>(
`/api/v1/competition-winners/${id}/scores`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
}
export async function fetchAdminCompetitionWinnerPreview(id: number): Promise<CompetitionWinnerAdminPreviewResponse> {
return apiFetch<CompetitionWinnerAdminPreviewResponse>(`/api/v1/competition-winners/${id}/preview`)
}
export async function fetchAdminCompetitionWinnerWinners(id: number): Promise<CompetitionWinnerAdminWinnersResponse> {
return apiFetch<CompetitionWinnerAdminWinnersResponse>(`/api/v1/competition-winners/${id}/winners`)
}
export async function publishAdminCompetitionWinner(id: number): Promise<CompetitionWinnerAdminActionResponse> {
return apiFetch<CompetitionWinnerAdminActionResponse>(`/api/v1/competition-winners/${id}/publish`, {
method: 'POST',
})
}
export async function lockAdminCompetitionWinner(id: number): Promise<CompetitionWinnerAdminActionResponse> {
return apiFetch<CompetitionWinnerAdminActionResponse>(`/api/v1/competition-winners/${id}/lock`, {
method: 'POST',
})
}
export async function unlockAdminCompetitionWinner(id: number): Promise<CompetitionWinnerAdminActionResponse> {
return apiFetch<CompetitionWinnerAdminActionResponse>(`/api/v1/competition-winners/${id}/unlock`, {
method: 'POST',
})
}
export async function exportAdminCompetitionWinnerQuiz(
id: number,
payload?: { class_section_id?: number | null; semester?: string | null; school_year?: string | null },
): Promise<CompetitionWinnerExportQuizResponse> {
return apiFetch<CompetitionWinnerExportQuizResponse>(`/api/v1/competition-winners/${id}/export-quiz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload ?? {}),
})
}
export async function saveCompetitionScores( export async function saveCompetitionScores(
id: number, id: number,
payload: { class_section_id?: number | null; scores: Record<string, number | string> }, payload: { class_section_id?: number | null; scores: Record<string, number | string> },
@@ -912,8 +1121,9 @@ export async function notifyTeacherSubmissions(payload: {
) )
} }
export async function fetchSubjectCurriculum(): Promise<SubjectCurriculumResponse> { export async function fetchSubjectCurriculum(schoolYear?: string | null): Promise<SubjectCurriculumResponse> {
return apiFetch<SubjectCurriculumResponse>('/api/v1/subjects/curriculum') const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<SubjectCurriculumResponse>(`/api/v1/subjects/curriculum${query}`)
} }
export async function createSubjectCurriculum(payload: { export async function createSubjectCurriculum(payload: {
@@ -922,6 +1132,7 @@ export async function createSubjectCurriculum(payload: {
unit_number?: number | null unit_number?: number | null
unit_title?: string | null unit_title?: string | null
chapter_name: string chapter_name: string
school_year?: string | null
}): Promise<ApiEnvelope<{ entry?: unknown }>> { }): Promise<ApiEnvelope<{ entry?: unknown }>> {
return apiFetch<ApiEnvelope<{ entry?: unknown }>>('/api/v1/subjects/curriculum', { return apiFetch<ApiEnvelope<{ entry?: unknown }>>('/api/v1/subjects/curriculum', {
method: 'POST', method: 'POST',
@@ -1015,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) {
@@ -1038,12 +1250,17 @@ export async function openProtectedApiFile(path: string): Promise<void> {
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} }
async function fetchMultipartJson<T>(path: string, formData: FormData): Promise<T> { async function fetchMultipartJson<T>(
path: string,
formData: FormData,
init: { method?: 'POST' | 'PUT' | 'PATCH' } = {},
): 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), { const res = await fetch(apiUrl(path), {
method: 'POST', method: init.method ?? 'POST',
headers, headers,
body: formData, body: formData,
}) })
@@ -1192,6 +1409,68 @@ export async function fetchUsers(params?: {
return apiFetch<UsersResponse>(`/api/v1/users${qs}`) return apiFetch<UsersResponse>(`/api/v1/users${qs}`)
} }
let discountParentSchoolIdMapPromise: Promise<Map<string, string>> | null = null
function userRowHasParentRole(row: UserListRow): boolean {
return (row.roles ?? []).some((role) => {
const label =
typeof role === 'string' ? role : `${role.name ?? ''} ${role.slug ?? ''}`
return label.toLowerCase().includes('parent')
})
}
async function fetchDiscountParentSchoolIdMap(): Promise<Map<string, string>> {
if (!discountParentSchoolIdMapPromise) {
discountParentSchoolIdMapPromise = (async () => {
const data = await fetchUsers({ sort: 'lastname', order: 'asc' })
const map = new Map<string, string>()
for (const row of data.users ?? []) {
if (!userRowHasParentRole(row)) continue
const user = row.user
const userId = user?.id
const schoolId = user?.school_id
if (userId == null || schoolId == null) continue
const normalizedSchoolId = String(schoolId).trim()
if (normalizedSchoolId === '') continue
map.set(String(userId), normalizedSchoolId)
}
return map
})().catch((error) => {
discountParentSchoolIdMapPromise = null
throw error
})
}
return discountParentSchoolIdMapPromise
}
async function enrichDiscountApplyContext(
context: DiscountApplyContextResponse,
): Promise<DiscountApplyContextResponse> {
const parents = context.parents ?? []
if (parents.length === 0) return context
try {
const schoolIdMap = await fetchDiscountParentSchoolIdMap()
return {
...context,
parents: parents.map((parent) => {
if (typeof parent.school_id === 'string' && parent.school_id.trim() !== '') {
return parent
}
const schoolId = schoolIdMap.get(String(parent.id))
if (!schoolId) return parent
return { ...parent, school_id: schoolId }
}),
}
} catch {
return context
}
}
function unwrapEnvelopeData(body: unknown): unknown { function unwrapEnvelopeData(body: unknown): unknown {
if (!body || typeof body !== 'object') return body if (!body || typeof body !== 'object') return body
const o = body as Record<string, unknown> const o = body as Record<string, unknown>
@@ -1443,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,
}),
}) })
} }
@@ -1506,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()
} }
@@ -1579,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> {
@@ -1678,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> {
@@ -1688,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()}`)
@@ -1700,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}`)
} }
@@ -1867,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,
}) })
@@ -2024,10 +2329,19 @@ export async function fetchDiscountApplyContext(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}` : ''
try {
const body = await apiFetch<DiscountApplyContextResponse & { data?: DiscountApplyContextResponse }>(
`/api/v1/discounts/options${suffix}`,
)
return enrichDiscountApplyContext(unwrapData(body))
} catch (err: unknown) {
if (!(err instanceof ApiHttpError) || err.status !== 404) throw err
}
const body = await apiFetch<DiscountApplyContextResponse & { data?: DiscountApplyContextResponse }>( const body = await apiFetch<DiscountApplyContextResponse & { data?: DiscountApplyContextResponse }>(
`/api/v1/discounts/apply-context${suffix}`, `/api/v1/discounts/apply-context${suffix}`,
) )
return unwrapData(body) return enrichDiscountApplyContext(unwrapData(body))
} }
export async function createDiscountVoucher(payload: Record<string, unknown>): Promise<{ ok?: boolean; message?: string }> { export async function createDiscountVoucher(payload: Record<string, unknown>): Promise<{ ok?: boolean; message?: string }> {
@@ -2193,11 +2507,31 @@ export async function fetchExpensesList(params?: {
} }
} }
function normalizeExpenseFormOptions(
body: Partial<ExpenseCreateFormResponse> & {
retailors?: string[]
staff?: ExpenseUserOption[]
},
): ExpenseCreateFormResponse {
return {
retailors: body.retailors ?? [],
users: body.users ?? body.staff ?? [],
}
}
export async function fetchExpenseCreateForm(): Promise<ExpenseCreateFormResponse> { export async function fetchExpenseCreateForm(): Promise<ExpenseCreateFormResponse> {
const body = await apiFetch<ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse }>( const body = await apiFetch<ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse }>(
'/api/v1/administrator/expenses/create-options', '/api/v1/administrator/expenses/options',
)
return normalizeExpenseFormOptions(
unwrapData(
body as ExpenseCreateFormResponse & {
data?: ExpenseCreateFormResponse
retailors?: string[]
staff?: ExpenseUserOption[]
},
),
) )
return unwrapData(body as ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse })
} }
export async function createExpense( export async function createExpense(
@@ -2207,26 +2541,53 @@ export async function createExpense(
} }
export async function fetchExpenseEdit(expenseId: number): Promise<ExpenseEditFormResponse> { export async function fetchExpenseEdit(expenseId: number): Promise<ExpenseEditFormResponse> {
const body = await apiFetch<ExpenseEditFormResponse & { data?: ExpenseEditFormResponse }>( const [expenseBody, optionsBody] = await Promise.all([
`/api/v1/administrator/expenses/${expenseId}/edit`, apiFetch<{ expense?: ExpenseDetail; data?: { expense?: ExpenseDetail } }>(
`/api/v1/administrator/expenses/${expenseId}`,
),
apiFetch<ExpenseCreateFormResponse & { data?: ExpenseCreateFormResponse }>(
'/api/v1/administrator/expenses/options',
),
])
const expenseData = unwrapData(
expenseBody as { expense?: ExpenseDetail; data?: { expense?: ExpenseDetail } },
) )
return unwrapData(body as ExpenseEditFormResponse & { data?: ExpenseEditFormResponse }) const optionsData = normalizeExpenseFormOptions(
unwrapData(
optionsBody as ExpenseCreateFormResponse & {
data?: ExpenseCreateFormResponse
retailors?: string[]
staff?: ExpenseUserOption[]
},
),
)
return {
expense: expenseData.expense ?? (expenseData as ExpenseDetail),
retailors: optionsData.retailors,
users: optionsData.users,
receipt_url: expenseData.expense?.receipt_url ?? null,
}
} }
export async function updateExpense( export async function updateExpense(
expenseId: number, expenseId: number,
formData: FormData, formData: FormData,
): Promise<{ ok?: boolean; message?: string }> { ): Promise<{ ok?: boolean; message?: string }> {
return fetchMultipartJson(`/api/v1/administrator/expenses/${expenseId}`, formData) const payload = new FormData()
formData.forEach((value, key) => {
payload.append(key, value)
})
payload.set('_method', 'PUT')
return fetchMultipartJson(`/api/v1/administrator/expenses/${expenseId}`, payload)
} }
export async function updateExpenseStatus(payload: { export async function updateExpenseStatus(payload: {
id: number id: number
status: 'approved' | 'denied' status: 'approved' | 'denied'
}): Promise<{ success?: boolean; error?: string }> { }): Promise<{ success?: boolean; error?: string }> {
return apiFetch(`/api/v1/administrator/expenses/update-status`, { return apiFetch(`/api/v1/administrator/expenses/${payload.id}/status`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify({ status: payload.status }),
}) })
} }
+39 -11
View File
@@ -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 -1
View File
@@ -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}`)
+144
View File
@@ -0,0 +1,144 @@
import { apiFetch } from './http'
type TrophyApiEnvelope<T> = {
ok?: boolean
data?: T
}
export type TrophyStudent = {
student_id?: number
class_section_id?: number
name?: string
firstname?: string | null
lastname?: string | null
school_id?: string | null
gender?: string | null
fall_score?: number | null
spring_score?: number | null
year_score?: number | null
projected_trophy?: boolean
predicted?: boolean
actual?: boolean
status?: string
}
export type TrophyProjectionClassResult = {
section_id: number
section_name: string
students: TrophyStudent[]
threshold: number | null
trophy_count: number
student_count: number
scored_count: number
method: string
boys: number
girls: number
trophy_boys: number
trophy_girls: number
pct_boys: number
pct_girls: number
pct_trophy_boys: number
pct_trophy_girls: number
pct_trophy_total: number
}
export type TrophyProjectionPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyProjectionClassResult[]
summary: Record<string, number>
}
export type TrophyWinnersClassResult = {
section_id: number
section_name: string
threshold: number | null
winners: TrophyStudent[]
student_count: number
boys: number
girls: number
trophy_boys: number
trophy_girls: number
pct_boys: number
pct_girls: number
pct_trophy_boys: number
pct_trophy_girls: number
pct_trophy_total: number
}
export type TrophyWinnersPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyWinnersClassResult[]
summary: Record<string, number>
}
export type TrophyFinalClassResult = {
section_id: number
section_name: string
students: TrophyStudent[]
student_count: number
fall_threshold: number | null
year_threshold: number | null
predicted_count: number
actual_count: number
confirmed: number
surprises: number
missed: number
accuracy: number
}
export type TrophyGenderSummary = {
total: number
boys: number
girls: number
other: number
pct_boys: number
pct_girls: number
pct_other: number
}
export type TrophyFinalPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyFinalClassResult[]
summary: Record<string, number>
winner_gender_summary: TrophyGenderSummary
all_gender_summary: TrophyGenderSummary
pass_gender_summary: TrophyGenderSummary
winner_stickers: Array<{ name: string; section: string }>
}
type TrophyFilters = {
school_year?: string
percentile?: string | number
}
function withQuery(path: string, params?: TrophyFilters) {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', String(params.school_year))
if (params?.percentile !== undefined && String(params.percentile).trim() !== '') {
qs.set('percentile', String(params.percentile))
}
return qs.size > 0 ? `${path}?${qs.toString()}` : path
}
async function fetchTrophyPayload<T>(path: string, params?: TrophyFilters): Promise<T> {
const res = await apiFetch<TrophyApiEnvelope<T>>(withQuery(path, params))
return (res.data ?? {}) as T
}
export function fetchTrophyProjection(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyProjectionPayload>('/api/v1/administrator/trophy', params)
}
export function fetchTrophyWinners(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyWinnersPayload>('/api/v1/administrator/trophy/winners', params)
}
export function fetchTrophyFinal(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyFinalPayload>('/api/v1/administrator/trophy/final', params)
}
+145
View File
@@ -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 = {
@@ -639,6 +667,7 @@ export type UserListRow = {
firstname?: string | null firstname?: string | null
lastname?: string | null lastname?: string | null
email?: string | null email?: string | null
school_id?: string | number | null
status?: string | null status?: string | null
cellphone?: string | null cellphone?: string | null
gender?: string | null gender?: string | null
@@ -978,6 +1007,7 @@ export type SubjectCurriculumEntryRow = {
unit_number?: number | string | null unit_number?: number | string | null
unit_title?: string | null unit_title?: string | null
chapter_name: string chapter_name: string
school_year?: string | null
} }
export type SubjectCurriculumResponse = { export type SubjectCurriculumResponse = {
@@ -985,6 +1015,8 @@ export type SubjectCurriculumResponse = {
classes: SubjectCurriculumClassRow[] classes: SubjectCurriculumClassRow[]
entries: SubjectCurriculumEntryRow[] entries: SubjectCurriculumEntryRow[]
subject_labels: Record<string, string> subject_labels: Record<string, string>
school_year?: string | null
school_years?: string[]
} }
export type PublicCompetitionRow = { export type PublicCompetitionRow = {
@@ -1054,6 +1086,119 @@ export type CompetitionScoresIndexResponse = {
hasClasses?: boolean hasClasses?: boolean
} }
export type CompetitionWinnerAdminCompetitionRow = CompetitionScoresIndexCompetitionRow & {
semester?: string | null
school_year?: string | null
start_date?: string | null
end_date?: string | null
published_at?: string | null
locked_at?: string | null
}
export type CompetitionWinnerAdminIndexResponse = {
ok: boolean
competitions?: CompetitionWinnerAdminCompetitionRow[]
sectionMap?: Record<string, string>
message?: string
}
export type CompetitionWinnerAdminClassSectionRow = {
class_section_id?: number
class_section_name?: string | null
}
export type CompetitionWinnerAdminClassRow = {
class_section_id: number
class_section_name: string
student_count: number
auto_winners: number
override_winners?: number | null
final_winners: number
question_count?: number | null
prize_1?: number | null
prize_2?: number | null
prize_3?: number | null
prize_4?: number | null
prize_5?: number | null
prize_6?: number | null
}
export type CompetitionWinnerAdminFormResponse = {
ok: boolean
mode?: 'create' | 'edit'
competition?: PublicCompetitionRow | null
classSections?: CompetitionWinnerAdminClassSectionRow[]
classRows?: CompetitionWinnerAdminClassRow[]
defaultSemester?: string | null
defaultSchoolYear?: string | null
message?: string
}
export type CompetitionWinnerAdminActionResponse = {
ok: boolean
id?: number
message?: string
}
export type CompetitionWinnerAdminScoresResponse = {
ok: boolean
competition?: PublicCompetitionRow
students?: CompetitionScoreStudentRow[]
scoreMap?: Record<string, number | string>
classSections?: CompetitionWinnerAdminClassSectionRow[]
activeClassSectionId?: number
classSectionName?: string | null
classSelectionLocked?: boolean
classStudentCount?: number
classAutoWinners?: number
classOverrideWinners?: number | null
classFinalWinners?: number
classQuestionCount?: number | null
classPrizeMap?: Record<string, number | null>
message?: string
}
export type CompetitionWinnerPreviewStudentRow = {
student_id?: number
name?: string | null
score?: number | string | null
rank?: number
prize_amount?: number | string | null
}
export type CompetitionWinnerAdminPreviewResponse = {
ok: boolean
competition?: PublicCompetitionRow
classMap?: Record<string, string>
classCounts?: Record<string, number>
overrideMap?: Record<string, number>
questionCounts?: Record<string, number>
prizeMap?: Record<string, Record<string, number | null>>
winnersCountByClass?: Record<string, number>
rankedByClass?: Record<string, CompetitionWinnerPreviewStudentRow[]>
topByClass?: Record<string, CompetitionWinnerPreviewStudentRow[]>
message?: string
}
export type CompetitionWinnerAdminWinnerRow = PublicCompetitionWinnerRow & {
school_id?: string | null
photo_consent?: number | boolean | null
}
export type CompetitionWinnerAdminWinnersResponse = {
ok: boolean
competition?: PublicCompetitionRow
rows?: CompetitionWinnerAdminWinnerRow[]
sectionMap?: Record<string, string>
message?: string
}
export type CompetitionWinnerExportQuizResponse = {
ok: boolean
message?: string
quizIndexes?: Record<string, number>
}
export type CompetitionScoreStudentRow = { export type CompetitionScoreStudentRow = {
id?: number id?: number
student_id?: number student_id?: number
+55 -9
View File
@@ -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 }
} }
+16 -11
View File
@@ -1,5 +1,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
import type { LegacySchoolYearValue } from '../lib/schoolYearOptions'
export type CiFlashMessage = { export type CiFlashMessage = {
id?: string id?: string
@@ -61,19 +63,22 @@ export function CiAcademicFilter({
classSectionId, classSectionId,
onApply, onApply,
}: { }: {
schoolYears?: Array<string | { school_year: string }> schoolYears?: LegacySchoolYearValue[]
selectedYear?: string | null selectedYear?: string | null
selectedSemester?: string | null selectedSemester?: string | null
classSectionId?: string | number | null classSectionId?: string | number | null
onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void
}) { }) {
const years = (schoolYears ?? []).map((year) => (typeof year === 'string' ? year : year.school_year)) const { options, selectedYear: defaultYear } = useSchoolYearOptions({
const [schoolYear, setSchoolYear] = useState(selectedYear ?? years[0] ?? '') legacyYears: schoolYears,
preferredYear: selectedYear,
})
const [schoolYear, setSchoolYear] = useState(selectedYear ?? defaultYear)
const [semester, setSemester] = useState(selectedSemester ?? '') const [semester, setSemester] = useState(selectedSemester ?? '')
useEffect(() => { useEffect(() => {
setSchoolYear(selectedYear ?? years[0] ?? '') setSchoolYear(selectedYear ?? defaultYear)
}, [selectedYear, years.join('|')]) }, [defaultYear, selectedYear])
useEffect(() => { useEffect(() => {
setSemester(selectedSemester ?? '') setSemester(selectedSemester ?? '')
@@ -104,10 +109,10 @@ export function CiAcademicFilter({
value={schoolYear} value={schoolYear}
onChange={(event) => setSchoolYear(event.target.value)} onChange={(event) => setSchoolYear(event.target.value)}
> >
{years.length > 0 ? ( {options.length > 0 ? (
years.map((year) => ( options.map((year) => (
<option key={year} value={year}> <option key={year.value} value={year.value}>
{year} {year.label}
</option> </option>
)) ))
) : ( ) : (
@@ -143,9 +148,9 @@ export function CiAcademicFilter({
type="button" type="button"
className="btn btn-outline-secondary btn-sm" className="btn btn-outline-secondary btn-sm"
onClick={() => { onClick={() => {
setSchoolYear(years[0] ?? '') setSchoolYear(defaultYear)
setSemester('') setSemester('')
onApply?.({ schoolYear: years[0] ?? '', semester: '', classSectionId: undefined }) onApply?.({ schoolYear: defaultYear, semester: '', classSectionId: undefined })
}} }}
> >
Reset Reset
@@ -0,0 +1,23 @@
import { useSchoolYear } from '../../context/SchoolYearContext'
export function ReadOnlyBanner({ className = '' }: { className?: string }) {
const { selectedSchoolYear, isReadOnlyYear } = useSchoolYear()
if (!selectedSchoolYear || !isReadOnlyYear) return null
return (
<div className={`alert alert-warning d-flex align-items-start gap-2 mb-3 ${className}`.trim()} role="status">
<i className="bi bi-lock-fill mt-1" aria-hidden />
<div>
<div className="fw-semibold">
Viewing archived school year: {selectedSchoolYear.name}
</div>
<div className="small">
Read-only mode. Add, edit, delete, import, attendance, grading, invoice, payment,
promotion, assignment, upload, and year-bound communication actions should remain disabled.
The API still has to reject forbidden writes, because buttons are not security.
</div>
</div>
</div>
)
}
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function ReadOnlyGuard({
children,
fallback,
}: {
children: ReactNode
fallback?: ReactNode
}) {
const { isReadOnlyYear, selectedSchoolYear } = useSchoolYear()
if (!isReadOnlyYear) return <>{children}</>
return (
<div className="border rounded p-3 bg-light text-muted">
{fallback ?? `This action is disabled because ${selectedSchoolYear?.name ?? 'the selected year'} is read-only.`}
</div>
)
}
@@ -0,0 +1,52 @@
import { useId } from 'react'
import { useSchoolYear } from '../../context/SchoolYearContext'
import { isCurrentSchoolYear } from '../../lib/schoolYearSelection'
import { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
export function SchoolYearSelector({ compact = false }: { compact?: boolean }) {
const {
schoolYears,
selectedSchoolYear,
isLoadingSchoolYears,
schoolYearError,
setSelectedSchoolYearName,
} = useSchoolYear()
const selectId = useId()
if (schoolYears.length === 0 && !isLoadingSchoolYears) {
return schoolYearError ? (
<span className="badge text-bg-warning" title={schoolYearError}>
School years unavailable
</span>
) : null
}
return (
<div className={`school-year-selector d-flex align-items-center gap-2 ${compact ? 'school-year-selector--compact' : ''}`}>
<label className="form-label mb-0 small text-nowrap" htmlFor={selectId}>
School Year
</label>
<select
id={selectId}
className="form-select form-select-sm"
value={selectedSchoolYear?.name ?? ''}
disabled={isLoadingSchoolYears || schoolYears.length === 0}
onChange={(event) => {
setSelectedSchoolYearName(event.target.value || null)
}}
>
{isLoadingSchoolYears && schoolYears.length === 0 ? (
<option value="">Loading</option>
) : null}
{schoolYears.map((year) => (
<option key={year.name} value={year.name}>
{year.name}
{isCurrentSchoolYear(year) ? ' Current' : ''}
{year.status && !isCurrentSchoolYear(year) ? ` ${year.status}` : ''}
</option>
))}
</select>
{selectedSchoolYear ? <SchoolYearStatusBadge status={selectedSchoolYear.status} /> : null}
</div>
)
}
@@ -0,0 +1,31 @@
import type { SchoolYearRecord } from '../../api/schoolYears'
function statusBadgeVariant(status: string | null | undefined): string {
switch (String(status ?? '').toLowerCase()) {
case 'active':
return 'success'
case 'closed':
return 'danger'
case 'archived':
return 'dark'
case 'draft':
return 'warning'
default:
return 'secondary'
}
}
export function SchoolYearStatusBadge({
status,
className = '',
}: {
status: SchoolYearRecord['status'] | null | undefined
className?: string
}) {
const label = String(status ?? 'unknown').trim() || 'unknown'
return (
<span className={`badge text-bg-${statusBadgeVariant(status)} ${className}`.trim()}>
{label.toUpperCase()}
</span>
)
}
+4
View File
@@ -0,0 +1,4 @@
export { ReadOnlyBanner } from './ReadOnlyBanner'
export { ReadOnlyGuard } from './ReadOnlyGuard'
export { SchoolYearSelector } from './SchoolYearSelector'
export { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
+164
View File
@@ -0,0 +1,164 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { useSearchParams } from 'react-router-dom'
import { fetchSchoolYears, type SchoolYearRecord } from '../api/schoolYears'
import { useAuth } from '../auth/AuthProvider'
import {
clearSelectedSchoolYear,
getStoredSelectedSchoolYearName,
isCurrentSchoolYear,
isReadOnlySchoolYear,
storeSelectedSchoolYear,
} from '../lib/schoolYearSelection'
type SchoolYearContextValue = {
schoolYears: SchoolYearRecord[]
selectedSchoolYear: SchoolYearRecord | null
selectedSchoolYearName: string | null
activeSchoolYear: SchoolYearRecord | null
isLoadingSchoolYears: boolean
schoolYearError: string | null
isReadOnlyYear: boolean
setSelectedSchoolYearName: (name: string | null) => void
refreshSchoolYears: () => Promise<void>
}
const SchoolYearContext = createContext<SchoolYearContextValue | null>(null)
function yearNameFromSearchParams(searchParams: URLSearchParams): string | null {
return (
searchParams.get('school_year')?.trim() ||
searchParams.get('schoolYear')?.trim() ||
null
)
}
function resolveSelectedYear(params: {
years: SchoolYearRecord[]
searchParams: URLSearchParams
requestedName: string | null
}): SchoolYearRecord | null {
const { years, searchParams, requestedName } = params
if (years.length === 0) return null
const urlName = yearNameFromSearchParams(searchParams)
const storedName = getStoredSelectedSchoolYearName()
const byRequestedName = requestedName ? years.find((year) => year.name === requestedName) : null
if (byRequestedName) return byRequestedName
const byUrlName = urlName ? years.find((year) => year.name === urlName) : null
if (byUrlName) return byUrlName
const byStoredName = storedName ? years.find((year) => year.name === storedName) : null
if (byStoredName) return byStoredName
return years.find(isCurrentSchoolYear) ?? years[0] ?? null
}
export function SchoolYearProvider({ children }: { children: ReactNode }) {
const { token } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const [schoolYears, setSchoolYears] = useState<SchoolYearRecord[]>([])
const [requestedYearName, setRequestedYearName] = useState<string | null>(null)
const [isLoadingSchoolYears, setIsLoadingSchoolYears] = useState(false)
const [schoolYearError, setSchoolYearError] = useState<string | null>(null)
const refreshSchoolYears = useCallback(async () => {
if (!token) {
setSchoolYears([])
setSchoolYearError(null)
clearSelectedSchoolYear()
return
}
setIsLoadingSchoolYears(true)
setSchoolYearError(null)
try {
const years = await fetchSchoolYears()
setSchoolYears(years)
} catch (error) {
setSchoolYears([])
setSchoolYearError(error instanceof Error ? error.message : 'Unable to load school years.')
} finally {
setIsLoadingSchoolYears(false)
}
}, [token])
useEffect(() => {
void refreshSchoolYears()
}, [refreshSchoolYears])
const selectedSchoolYear = useMemo(
() =>
resolveSelectedYear({
years: schoolYears,
searchParams,
requestedName: requestedYearName,
}),
[requestedYearName, schoolYears, searchParams],
)
const activeSchoolYear = useMemo(
() => schoolYears.find(isCurrentSchoolYear) ?? null,
[schoolYears],
)
useEffect(() => {
storeSelectedSchoolYear(selectedSchoolYear)
if (!selectedSchoolYear || !token) return
setSearchParams((current) => {
const next = new URLSearchParams(current)
const currentName = next.get('school_year')
next.delete('school_year_id')
next.delete('year')
if (currentName === selectedSchoolYear.name && next.toString() === current.toString()) {
return current
}
next.set('school_year', selectedSchoolYear.name)
return next
}, { replace: true })
}, [selectedSchoolYear, setSearchParams, token])
const value = useMemo<SchoolYearContextValue>(() => ({
schoolYears,
selectedSchoolYear,
selectedSchoolYearName: selectedSchoolYear?.name ?? null,
activeSchoolYear,
isLoadingSchoolYears,
schoolYearError,
isReadOnlyYear: isReadOnlySchoolYear(selectedSchoolYear),
setSelectedSchoolYearName: setRequestedYearName,
refreshSchoolYears,
}), [
activeSchoolYear,
isLoadingSchoolYears,
refreshSchoolYears,
schoolYearError,
schoolYears,
selectedSchoolYear,
])
return <SchoolYearContext.Provider value={value}>{children}</SchoolYearContext.Provider>
}
export function useSchoolYear(): SchoolYearContextValue {
const context = useContext(SchoolYearContext)
if (!context) {
throw new Error('useSchoolYear must be used inside SchoolYearProvider.')
}
return context
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useMemo, useState } from 'react'
import { fetchSchoolYears, type SchoolYearRecord } from '../api/schoolYears'
import {
defaultSchoolYearOption,
mergeSchoolYearOptions,
type LegacySchoolYearValue,
} from '../lib/schoolYearOptions'
let cachedSchoolYears: SchoolYearRecord[] | null = null
let cachedSchoolYearsPromise: Promise<SchoolYearRecord[]> | null = null
async function loadCanonicalSchoolYears(): Promise<SchoolYearRecord[]> {
if (cachedSchoolYears) return cachedSchoolYears
if (!cachedSchoolYearsPromise) {
cachedSchoolYearsPromise = fetchSchoolYears()
.then((years) => {
cachedSchoolYears = years
return years
})
.finally(() => {
cachedSchoolYearsPromise = null
})
}
return cachedSchoolYearsPromise
}
export function useSchoolYearOptions({
legacyYears,
preferredYear,
enabled = true,
}: {
legacyYears?: LegacySchoolYearValue[]
preferredYear?: string | null
enabled?: boolean
}) {
const [canonicalYears, setCanonicalYears] = useState<SchoolYearRecord[]>(cachedSchoolYears ?? [])
useEffect(() => {
if (!enabled) return
if (cachedSchoolYears) {
setCanonicalYears(cachedSchoolYears)
return
}
let cancelled = false
loadCanonicalSchoolYears()
.then((years) => {
if (!cancelled) setCanonicalYears(years)
})
.catch(() => {
if (!cancelled) setCanonicalYears([])
})
return () => {
cancelled = true
}
}, [enabled])
const options = useMemo(
() =>
mergeSchoolYearOptions({
canonicalYears,
legacyYears,
extraYears: [preferredYear],
}),
[canonicalYears, legacyYears, preferredYear],
)
const selectedYear = useMemo(
() => defaultSchoolYearOption(options, preferredYear),
[options, preferredYear],
)
const currentYear = useMemo(
() => options.find((option) => option.isCurrent)?.value ?? selectedYear,
[options, selectedYear],
)
return {
canonicalYears,
currentYear,
options,
selectedYear,
}
}
+27
View File
@@ -0,0 +1,27 @@
import { useMemo } from 'react'
import { useSchoolYear } from '../context/SchoolYearContext'
export function useSelectedSchoolYearParams() {
const { selectedSchoolYear, selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
return useMemo(() => {
const schoolYear = selectedSchoolYearName
const query = new URLSearchParams()
const headers: Record<string, string> = {}
if (schoolYear) {
query.set('school_year', schoolYear)
headers['X-School-Year'] = schoolYear
}
const queryString = query.toString()
return {
schoolYear,
query,
queryString,
headers,
isReadOnly: selectedSchoolYear ? isReadOnlyYear : false,
}
}, [isReadOnlyYear, selectedSchoolYear, selectedSchoolYearName])
}
+7 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' import { 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>
+45 -6
View File
@@ -1,13 +1,34 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
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,
} from '../lib/ciSpaPaths' } from '../lib/ciSpaPaths'
const navLabelCollator = new Intl.Collator(undefined, {
sensitivity: 'base',
numeric: true,
})
function compareNavItemsByLabel(a: NavItem, b: NavItem): number {
const labelResult = navLabelCollator.compare(a.label?.trim() || '', b.label?.trim() || '')
return labelResult || a.id - b.id
}
function sortNavTree(items: NavItem[]): NavItem[] {
return [...items]
.map((item) => ({
...item,
children: sortNavTree(item.children ?? []),
}))
.sort(compareNavItemsByLabel)
}
function normalizeNavItems(payload: unknown): NavItem[] { function normalizeNavItems(payload: unknown): NavItem[] {
const rawItems = Array.isArray(payload) const rawItems = Array.isArray(payload)
? (payload as NavItem[]) ? (payload as NavItem[])
@@ -20,7 +41,7 @@ function normalizeNavItems(payload: unknown): NavItem[] {
if (rawItems.length === 0) return [] if (rawItems.length === 0) return []
if (rawItems.some((item) => Array.isArray(item.children) && item.children.length > 0)) { if (rawItems.some((item) => Array.isArray(item.children) && item.children.length > 0)) {
return rawItems return sortNavTree(rawItems)
} }
const byId = new Map<number, NavItem>() const byId = new Map<number, NavItem>()
@@ -38,7 +59,7 @@ function normalizeNavItems(payload: unknown): NavItem[] {
} }
} }
return tree return sortNavTree(tree)
} }
function branchContainsPath(item: NavItem, pathname: string): boolean { function branchContainsPath(item: NavItem, pathname: string): boolean {
@@ -59,8 +80,6 @@ function NavTree({ items, pathname }: { items: NavItem[]; pathname: string }) {
function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pathname: string }) { function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pathname: string }) {
const enabled = item.is_enabled !== 0 const enabled = item.is_enabled !== 0
if (!enabled) return null
const label = item.label?.trim() || 'Menu' const label = item.label?.trim() || 'Menu'
const url = item.url?.trim() || '' const url = item.url?.trim() || ''
const external = isExternalNavUrl(url) const external = isExternalNavUrl(url)
@@ -71,7 +90,7 @@ function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pa
<i className={`${item.icon_class} me-2`} aria-hidden /> <i className={`${item.icon_class} me-2`} aria-hidden />
) : null ) : null
const children = (item.children ?? []).filter((c) => c.is_enabled !== 0) const children = sortNavTree((item.children ?? []).filter((c) => c.is_enabled !== 0))
const hasChildren = children.length > 0 const hasChildren = children.length > 0
const [open, setOpen] = useState(() => hasChildren && branchContainsPath(item, pathname)) const [open, setOpen] = useState(() => hasChildren && branchContainsPath(item, pathname))
@@ -81,6 +100,8 @@ function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pa
} }
}, [hasChildren, item, pathname]) }, [hasChildren, item, pathname])
if (!enabled) return null
const linkInner = ( const linkInner = (
<> <>
{icon} {icon}
@@ -187,6 +208,17 @@ export function ManagementLayout() {
} }
} catch (e) { } catch (e) {
if (!cancelled) { if (!cancelled) {
if (e instanceof ApiHttpError && e.status === 401) {
logout()
navigate('/login', {
replace: true,
state: {
from: `${location.pathname}${location.search}${location.hash}`,
message: 'Your session expired. Please sign in again.',
},
})
return
}
setItems([]) setItems([])
setMenuError(e instanceof Error ? e.message : 'Unable to load menu.') setMenuError(e instanceof Error ? e.message : 'Unable to load menu.')
} }
@@ -195,10 +227,15 @@ export function ManagementLayout() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [location.hash, location.pathname, location.search, logout, navigate])
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">
@@ -207,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"
@@ -278,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>
+10 -7
View File
@@ -3,20 +3,23 @@ const PROD_API_ORIGIN = 'https://api.alrahmaisgl.org'
/** /**
* Laravel API base URL. * Laravel API base URL.
* *
* **Vite dev:** same-origin `/api/...`; `vite.config.ts` proxies to the API (default `http://localhost:8080`). * Dev defaults to same-origin `/api/...` so Vite can proxy requests to Laravel.
* Override proxy target with **`VITE_PROXY_API`**. **`VITE_API_ORIGIN` is not used in dev** (avoids cross-origin from the browser). * Set `VITE_API_URL` when you want the browser to call Laravel directly, e.g.
* `VITE_API_URL=http://localhost:8000 npm run dev`.
* *
* **Production build:** `VITE_API_ORIGIN` if set, else `PROD_API_ORIGIN`. * If `VITE_API_URL` is not set in dev, `vite.config.ts` proxies `/api` to
* `VITE_PROXY_API` or `http://localhost:8000` by default.
*/ */
export function apiOrigin(): string { export function apiOrigin(): string {
const v = import.meta.env.VITE_API_URL
if (typeof v === 'string' && v.trim()) {
return v.replace(/\/$/, '')
}
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
return '' return ''
} }
const v = import.meta.env.VITE_API_ORIGIN
if (typeof v === 'string' && v.trim()) {
return v.replace(/\/$/, '')
}
return PROD_API_ORIGIN return PROD_API_ORIGIN
} }
+4
View File
@@ -8,6 +8,10 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
const path = u.replace(/^\/+/, '').replace(/\/+$/, '') const path = u.replace(/^\/+/, '').replace(/\/+$/, '')
if (!path) return '/app/home' if (!path) return '/app/home'
if (path === 'app' || path.startsWith('app/')) {
return `/${path}`
}
const querySuffix = path.includes('?') ? `?${path.slice(path.indexOf('?') + 1)}` : '' const querySuffix = path.includes('?') ? `?${path.slice(path.indexOf('?') + 1)}` : ''
const pathOnly = path.split('?')[0] ?? path const pathOnly = path.split('?')[0] ?? path
+142
View File
@@ -0,0 +1,142 @@
import type { SchoolYearRecord } from '../api/schoolYears'
export type LegacySchoolYearValue =
| string
| {
id?: number | null
name?: string | null
school_year?: string | null
status?: string | null
is_current?: boolean | null
}
export type SchoolYearOption = {
id?: number
value: string
label: string
status?: string | null
isCurrent: boolean
}
export function buildCalendarSchoolYear(date = new Date()): string {
const year = date.getFullYear()
return date.getMonth() >= 8 ? `${year}-${year + 1}` : `${year - 1}-${year}`
}
export function normalizeSchoolYearValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
function optionLabel(name: string, status?: string | null, isCurrent = false): string {
const normalizedStatus = String(status ?? '').trim().toLowerCase()
if (isCurrent) {
return normalizedStatus && normalizedStatus !== 'active'
? `${name} (Current, ${capitalize(normalizedStatus)})`
: `${name} (Current)`
}
if (!normalizedStatus || normalizedStatus === 'active') {
return name
}
return `${name} (${capitalize(normalizedStatus)})`
}
function makeOption(
name: string,
status?: string | null,
isCurrent = false,
id?: number | null,
): SchoolYearOption | null {
const value = normalizeSchoolYearValue(name)
if (!value) return null
return {
id: id == null ? undefined : Number(id),
value,
label: optionLabel(value, status, isCurrent),
status: status ?? undefined,
isCurrent,
}
}
function optionFromRecord(record: SchoolYearRecord): SchoolYearOption | null {
return makeOption(record.name, record.status, Boolean(record.is_current || record.isCurrent), record.id)
}
function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null {
if (typeof value === 'string') {
return makeOption(value)
}
return makeOption(
value.name ?? value.school_year ?? '',
value.status ?? undefined,
Boolean(value.is_current),
value.id,
)
}
function compareSchoolYears(left: SchoolYearOption, right: SchoolYearOption): number {
if (left.isCurrent !== right.isCurrent) {
return Number(right.isCurrent) - Number(left.isCurrent)
}
return right.value.localeCompare(left.value, undefined, {
numeric: true,
sensitivity: 'base',
})
}
export function mergeSchoolYearOptions(params: {
canonicalYears?: SchoolYearRecord[]
legacyYears?: LegacySchoolYearValue[]
extraYears?: Array<string | null | undefined>
}): SchoolYearOption[] {
const map = new Map<string, SchoolYearOption>()
for (const record of params.canonicalYears ?? []) {
const option = optionFromRecord(record)
if (option) map.set(option.value, option)
}
for (const value of params.legacyYears ?? []) {
const option = optionFromLegacy(value)
if (!option || map.has(option.value)) continue
map.set(option.value, option)
}
for (const extraYear of params.extraYears ?? []) {
const option = makeOption(String(extraYear ?? ''))
if (!option || map.has(option.value)) continue
map.set(option.value, option)
}
return [...map.values()].sort(compareSchoolYears)
}
export function defaultSchoolYearOption(
options: SchoolYearOption[],
preferredYear?: string | null,
): string {
const preferred = normalizeSchoolYearValue(preferredYear)
if (preferred && options.some((option) => option.value === preferred)) {
return preferred
}
const current = options.find((option) => option.isCurrent)
if (current) return current.value
return options[0]?.value ?? buildCalendarSchoolYear()
}
export function canonicalSchoolYearName(records: SchoolYearRecord[]): string | null {
if (records.length === 0) return null
const current = records.find((record) => record.is_current)
return current?.name ?? records[0]?.name ?? null
}
+101
View File
@@ -0,0 +1,101 @@
import type { SchoolYearRecord } from '../api/schoolYears'
export const SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY = 'alrahma_selected_school_year_name'
export const SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY = 'alrahma_selected_school_year_status'
function safeLocalStorage(): Storage | null {
try {
return typeof window !== 'undefined' ? window.localStorage : null
} catch {
return null
}
}
export function isCurrentSchoolYear(year: Pick<SchoolYearRecord, 'is_current'> & { isCurrent?: boolean }): boolean {
return Boolean(year.is_current || year.isCurrent)
}
export function isReadOnlySchoolYearStatus(status: string | null | undefined): boolean {
const normalized = String(status ?? '').trim().toLowerCase()
return normalized === 'closed' || normalized === 'archived'
}
export function isReadOnlySchoolYear(year: SchoolYearRecord | null | undefined): boolean {
return year ? isReadOnlySchoolYearStatus(year.status) : false
}
export function getStoredSelectedSchoolYearName(): string | null {
const storage = safeLocalStorage()
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)?.trim() ?? ''
return value || null
}
export function getStoredSelectedSchoolYearStatus(): string | null {
const storage = safeLocalStorage()
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)?.trim() ?? ''
return value || null
}
export function storeSelectedSchoolYear(year: SchoolYearRecord | null): void {
const storage = safeLocalStorage()
if (!storage) return
if (!year) {
storage.removeItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)
storage.removeItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)
return
}
storage.setItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY, year.name)
storage.setItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY, year.status)
}
export function clearSelectedSchoolYear(): void {
storeSelectedSchoolYear(null)
}
export function selectedSchoolYearHeaders(): Record<string, string> {
const name = getStoredSelectedSchoolYearName()
const status = getStoredSelectedSchoolYearStatus()
const headers: Record<string, string> = {}
if (name) {
headers['X-School-Year'] = name
}
if (status) {
headers['X-School-Year-Status'] = status
}
return headers
}
export function buildSchoolYearQuery(params: {
schoolYearName?: string | null
existing?: string | URLSearchParams
}): string {
const query = new URLSearchParams(params.existing)
const name = params.schoolYearName?.trim()
if (name) {
query.set('school_year', name)
}
const value = query.toString()
return value ? `?${value}` : ''
}
export function withSelectedSchoolYearUrl(url: string): string {
const name = getStoredSelectedSchoolYearName()
if (!name) return url
const [base, hash = ''] = url.split('#', 2)
const [path, rawQuery = ''] = base.split('?', 2)
const query = new URLSearchParams(rawQuery)
query.delete('school_year_id')
query.delete('year')
query.set('school_year', name)
const next = `${path}?${query.toString()}`
return hash ? `${next}#${hash}` : next
}
+27 -19
View File
@@ -37,6 +37,27 @@ type FlatContact = {
phone: string 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>
+315 -125
View File
@@ -2,6 +2,11 @@ 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 { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import { useSchoolYear } from '../context/SchoolYearContext'
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
import { import {
assignTeacherClass, assignTeacherClass,
assignStudentClass, assignStudentClass,
@@ -23,7 +28,6 @@ import {
fetchClassAssignmentOverview, fetchClassAssignmentOverview,
fetchClassSections, fetchClassSections,
fetchExamDraftAdminData, fetchExamDraftAdminData,
fetchFamilyAdminIndex,
fetchGradingOverview, fetchGradingOverview,
fetchIpBans, fetchIpBans,
fetchLateSlipLogs, fetchLateSlipLogs,
@@ -978,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)
@@ -1027,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)
@@ -1050,12 +1054,6 @@ export function AdminCalendarEditPage() {
/** School calendar admin — `GET /api/v1/settings/school-calendar/events` (JWT). SPA: `/app/administrator/events` or `calendar_view`. */ /** School calendar admin — `GET /api/v1/settings/school-calendar/events` (JWT). SPA: `/app/administrator/events` or `calendar_view`. */
function currentSchoolYear(): string {
const now = new Date()
const y = now.getFullYear()
return now.getMonth() >= 8 ? `${y}-${y + 1}` : `${y - 1}-${y}`
}
export function AdminCalendarViewPage() { export function AdminCalendarViewPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
@@ -1063,24 +1061,28 @@ 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({
preferredYear: schoolYear,
})
useEffect(() => { useEffect(() => {
if (!schoolYear) { if (!schoolYear && currentYear) {
setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }), { replace: true }) setSearchParams(new URLSearchParams({ school_year: currentYear }), { replace: true })
} }
}, []) }, [currentYear, schoolYear, setSearchParams])
async function load() { async function load() {
setLoading(true) setLoading(true)
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)
} }
@@ -1088,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">
@@ -1108,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>
@@ -1117,21 +1119,49 @@ 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)
}} }}
> >
<div> <div>
<label htmlFor="cal-school-year" className="form-label">School Year</label> <label htmlFor="cal-school-year" className="form-label">School Year</label>
<input id="cal-school-year" name="school_year" className="form-control" defaultValue={schoolYear} placeholder="e.g. 2025-2026" /> <select
id="cal-school-year"
name="school_year"
className="form-select"
defaultValue={schoolYear || currentYear}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="cal-semester" className="form-label">Semester</label>
<select
id="cal-semester"
name="semester"
className="form-select"
defaultValue={semester}
>
<option value="">-</option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div> </div>
<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
className="btn btn-outline-secondary" className="btn btn-outline-secondary"
type="button" type="button"
onClick={() => setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }))} onClick={() =>
setSearchParams(new URLSearchParams(currentYear ? { school_year: currentYear } : {}))
}
> >
Reset Reset
</button> </button>
@@ -1147,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}>
@@ -1164,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 ? (
<> <>
@@ -1176,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>
@@ -1307,6 +1343,10 @@ export function AdminClassAssignmentPage() {
key: 'school_id', key: 'school_id',
direction: 'asc', direction: 'asc',
}) })
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: selectedYear,
})
async function load(year?: string | null) { async function load(year?: string | null) {
setLoading(true) setLoading(true)
@@ -1316,7 +1356,7 @@ export function AdminClassAssignmentPage() {
const classSections = data.classSections ?? [] const classSections = data.classSections ?? []
setSections(classSections) setSections(classSections)
setSchoolYears(data.schoolYears ?? []) setSchoolYears(data.schoolYears ?? [])
setSelectedYear(data.selectedYear ?? data.schoolYear ?? '') setSelectedYear(data.selectedYear ?? data.schoolYear ?? defaultYear)
setSelectedSemester(data.selectedSemester ?? data.semester ?? '') setSelectedSemester(data.selectedSemester ?? data.semester ?? '')
setExpandedSections(new Set(classSections.length > 0 ? ['0'] : [])) setExpandedSections(new Set(classSections.length > 0 ? ['0'] : []))
setError(null) setError(null)
@@ -1334,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
}) })
} }
@@ -1397,11 +1437,11 @@ export function AdminClassAssignmentPage() {
<select <select
className="form-select form-select-sm" className="form-select form-select-sm"
style={{ minWidth: 200 }} style={{ minWidth: 200 }}
value={selectedYear} value={selectedYear || defaultYear}
onChange={(event) => setSelectedYear(event.target.value)} onChange={(event) => setSelectedYear(event.target.value)}
> >
{schoolYears.map((year) => ( {options.map((year) => (
<option key={year} value={year}>{year}</option> <option key={year.value} value={year.value}>{year.label}</option>
))} ))}
</select> </select>
</div> </div>
@@ -2527,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(() => {
@@ -3449,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.')
@@ -3469,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>
@@ -3501,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>
@@ -3644,15 +3826,10 @@ export function AdminRemovedStudentsPage() {
return ( return (
<AdminParityShell title="Student Removal"> <AdminParityShell title="Student Removal">
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<form className="row g-2 align-items-end mb-3" onSubmit={(e) => { e.preventDefault(); void load() }}> <CiAcademicFilter
<div className="col-sm-4 col-lg-3"> selectedYear={schoolYear}
<label className="form-label">School Year</label> onApply={({ schoolYear: year }) => void load(year)}
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} placeholder="2025-2026" /> />
</div>
<div className="col-auto">
<button className="btn btn-primary" type="submit">Apply</button>
</div>
</form>
<div className="row g-4"> <div className="row g-4">
<div className="col-xl-6"> <div className="col-xl-6">
<div className="card shadow-sm"> <div className="card shadow-sm">
@@ -3793,7 +3970,8 @@ export function AdminSearchResultsPage() {
/** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */ /** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */
export function AdminSectionsPromotionTotalsPage() { export function AdminSectionsPromotionTotalsPage() {
const [schoolYear, setSchoolYear] = useState('') const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([]) const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -3824,26 +4002,14 @@ export function AdminSectionsPromotionTotalsPage() {
sections from these totals. sections from these totals.
</p> </p>
<div className="row g-2 align-items-end mb-3 flex-wrap"> <div className="d-flex flex-wrap align-items-start justify-content-between gap-2 mb-3">
<div className="col-sm-4 col-lg-3"> <CiAcademicFilter
<label className="form-label">School year</label> selectedYear={schoolYear}
<input onApply={({ schoolYear: year }) => void load(year)}
className="form-control" />
value={schoolYear} <Link className="btn btn-outline-primary" to={`/app/administrator/sections/auto-distribute${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
onChange={(e) => setSchoolYear(e.target.value)} Auto-distribute sections
placeholder="e.g. 2025-2026" </Link>
/>
</div>
<div className="col-auto">
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>
Refresh
</button>
</div>
<div className="col-auto">
<Link className="btn btn-outline-primary" to="/app/administrator/sections/auto-distribute">
Auto-distribute sections
</Link>
</div>
</div> </div>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
@@ -3892,7 +4058,8 @@ export function AdminSectionsPromotionTotalsPage() {
} }
export function AdminSectionsAutoDistributePage() { export function AdminSectionsAutoDistributePage() {
const [schoolYear, setSchoolYear] = useState('') const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [studentsPerSection, setStudentsPerSection] = useState(20) const [studentsPerSection, setStudentsPerSection] = useState(20)
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([]) const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -3932,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)
} }
} }
@@ -3941,15 +4107,16 @@ export function AdminSectionsAutoDistributePage() {
<AdminParityShell title="Auto-Distribute Students into Sections"> <AdminParityShell title="Auto-Distribute Students into Sections">
{message ? <div className="alert alert-info">{message}</div> : null} {message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-2 align-items-end mb-3"> <div className="row g-2 align-items-end mb-3">
<div className="col-sm-4 col-lg-3"> <div className="col-lg-6">
<label className="form-label">School Year</label> <CiAcademicFilter
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} /> selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
</div> </div>
<div className="col-sm-4 col-lg-3"> <div className="col-sm-4 col-lg-3">
<label className="form-label">Students per section</label> <label className="form-label">Students per section</label>
<input type="number" min={1} className="form-control" value={studentsPerSection} onChange={(e) => setStudentsPerSection(Number(e.target.value) || 1)} /> <input type="number" min={1} className="form-control" value={studentsPerSection} onChange={(e) => setStudentsPerSection(Number(e.target.value) || 1)} />
</div> </div>
<div className="col-auto"><button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Refresh Totals</button></div>
<div className="col-auto"><button className="btn btn-primary" type="button" onClick={() => void runAll()} disabled={rows.length === 0}>Generate All</button></div> <div className="col-auto"><button className="btn btn-primary" type="button" onClick={() => void runAll()} disabled={rows.length === 0}>Generate All</button></div>
</div> </div>
<div className="table-responsive"> <div className="table-responsive">
@@ -3980,6 +4147,7 @@ export function AdminSectionsAutoDistributePage() {
} }
export function AdminStudentClassAssignmentPage() { export function AdminStudentClassAssignmentPage() {
const [searchParams] = useSearchParams()
type StudentAssignmentSortKey = type StudentAssignmentSortKey =
| 'name' | 'name'
| 'age' | 'age'
@@ -3988,7 +4156,7 @@ export function AdminStudentClassAssignmentPage() {
| 'assigned_classes' | 'assigned_classes'
| 'registration_date' | 'registration_date'
| 'school_year' | 'school_year'
const [schoolYear, setSchoolYear] = useState('') const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [students, setStudents] = useState<StudentAssignmentRow[]>([]) const [students, setStudents] = useState<StudentAssignmentRow[]>([])
const [classes, setClasses] = useState<StudentAssignmentOption[]>([]) const [classes, setClasses] = useState<StudentAssignmentOption[]>([])
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null) const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
@@ -4103,11 +4271,10 @@ export function AdminStudentClassAssignmentPage() {
{message ? <div className="alert alert-info">{message}</div> : null} {message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-3 mb-4"> <div className="row g-3 mb-4">
<div className="col-lg-4"> <div className="col-lg-4">
<label className="form-label">School Year</label> <CiAcademicFilter
<div className="d-flex gap-2"> selectedYear={schoolYear}
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} /> onApply={({ schoolYear: year }) => void load(year)}
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button> />
</div>
</div> </div>
<div className="col-lg-8"> <div className="col-lg-8">
<div className="card shadow-sm"> <div className="card shadow-sm">
@@ -4211,6 +4378,7 @@ export function AdminStudentClassAssignmentPage() {
} }
export function AdminStudentProfilesPage() { export function AdminStudentProfilesPage() {
const [searchParams] = useSearchParams()
type StudentProfileSortKey = type StudentProfileSortKey =
| 'school_id' | 'school_id'
| 'firstname' | 'firstname'
@@ -4222,7 +4390,7 @@ export function AdminStudentProfilesPage() {
| 'is_active' | 'is_active'
| 'photo_consent' | 'photo_consent'
| 'registration_date' | 'registration_date'
const [schoolYear, setSchoolYear] = useState('') const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [students, setStudents] = useState<StudentDirectoryRow[]>([]) const [students, setStudents] = useState<StudentDirectoryRow[]>([])
const [editing, setEditing] = useState<StudentDirectoryRow | null>(null) const [editing, setEditing] = useState<StudentDirectoryRow | null>(null)
const [parentDetails, setParentDetails] = useState<Record<string, unknown> | null>(null) const [parentDetails, setParentDetails] = useState<Record<string, unknown> | null>(null)
@@ -4315,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(' ')
@@ -4345,10 +4513,10 @@ export function AdminStudentProfilesPage() {
<AdminParityShell title="Student Profiles"> <AdminParityShell title="Student Profiles">
{message ? <div className="alert alert-info">{message}</div> : null} {message ? <div className="alert alert-info">{message}</div> : null}
<div className="d-flex flex-wrap gap-2 mb-3 justify-content-between"> <div className="d-flex flex-wrap gap-2 mb-3 justify-content-between">
<div className="d-flex gap-2"> <CiAcademicFilter
<input className="form-control" style={{ maxWidth: 240 }} value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} placeholder="School year" /> selectedYear={schoolYear}
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button> onApply={({ schoolYear: year }) => void load(year)}
</div> />
<div className="input-group" style={{ maxWidth: 360 }}> <div className="input-group" style={{ maxWidth: 360 }}>
<input <input
className="form-control" className="form-control"
@@ -4388,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>
@@ -4452,9 +4620,11 @@ export function AdminStudentProfilesPage() {
} }
export function AdminSubjectCurriculumPage() { export function AdminSubjectCurriculumPage() {
const [searchParams] = useSearchParams()
const [classes, setClasses] = useState<Array<{ id: number; class_name: string }>>([]) const [classes, setClasses] = useState<Array<{ id: number; class_name: string }>>([])
const [entries, setEntries] = useState<SubjectCurriculumEntryRow[]>([]) const [entries, setEntries] = useState<SubjectCurriculumEntryRow[]>([])
const [subjects, setSubjects] = useState<Record<string, string>>({}) const [subjects, setSubjects] = useState<Record<string, string>>({})
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [form, setForm] = useState({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' }) const [form, setForm] = useState({ class_id: '', subject: 'islamic', unit_number: '', unit_title: '', chapter_name: '' })
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
@@ -4462,14 +4632,15 @@ export function AdminSubjectCurriculumPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
async function load() { async function load(year = schoolYear) {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const data = await fetchSubjectCurriculum() const data = await fetchSubjectCurriculum(year || undefined)
setClasses(data.classes ?? []) setClasses(data.classes ?? [])
setEntries(data.entries ?? []) setEntries(data.entries ?? [])
setSubjects(data.subject_labels ?? {}) setSubjects(data.subject_labels ?? {})
setSchoolYear(data.school_year ?? year ?? '')
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.') setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.')
} finally { } finally {
@@ -4491,6 +4662,7 @@ export function AdminSubjectCurriculumPage() {
unit_number: form.unit_number ? Number(form.unit_number) : null, unit_number: form.unit_number ? Number(form.unit_number) : null,
unit_title: form.unit_title || null, unit_title: form.unit_title || null,
chapter_name: form.chapter_name, chapter_name: form.chapter_name,
school_year: schoolYear || undefined,
} }
try { try {
if (editingId) { if (editingId) {
@@ -4528,6 +4700,10 @@ export function AdminSubjectCurriculumPage() {
<AdminParityShell title="Subject Curriculum"> <AdminParityShell title="Subject Curriculum">
{message ? <div className="alert alert-info">{message}</div> : null} {message ? <div className="alert alert-info">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="row g-4"> <div className="row g-4">
<div className="col-lg-5"> <div className="col-lg-5">
<div className="card shadow-sm"> <div className="card shadow-sm">
@@ -4583,17 +4759,17 @@ export function AdminSubjectCurriculumPage() {
<div className="card-header">Current curriculum entries</div> <div className="card-header">Current curriculum entries</div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-bordered table-hover align-middle mb-0"> <table className="table table-bordered table-hover align-middle mb-0">
<thead className="table-light"><tr><th>Class</th><th>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead> <thead className="table-light"><tr><th>Class</th><th>School Year</th><th>Subject</th><th>Unit</th><th>Unit title</th><th>Chapter / Surah</th><th>Actions</th></tr></thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={6} className="text-muted"> <td colSpan={7} className="text-muted">
Loading Loading
</td> </td>
</tr> </tr>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="text-muted"> <td colSpan={7} className="text-muted">
No curriculum records yet. No curriculum records yet.
</td> </td>
</tr> </tr>
@@ -4601,6 +4777,7 @@ export function AdminSubjectCurriculumPage() {
entries.map((entry) => ( entries.map((entry) => (
<tr key={entry.id}> <tr key={entry.id}>
<td>{entry.class_name ?? '—'}</td> <td>{entry.class_name ?? '—'}</td>
<td>{entry.school_year ?? schoolYear ?? '—'}</td>
<td>{subjects[entry.subject] ?? entry.subject}</td> <td>{subjects[entry.subject] ?? entry.subject}</td>
<td>{entry.unit_number ?? '—'}</td> <td>{entry.unit_number ?? '—'}</td>
<td>{entry.unit_title ?? '—'}</td> <td>{entry.unit_title ?? '—'}</td>
@@ -4646,7 +4823,8 @@ export function AdminSubjectCurriculumPage() {
} }
export function AdminTeacherClassAssignmentPage() { export function AdminTeacherClassAssignmentPage() {
const [schoolYear, setSchoolYear] = useState('') const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [teachers, setTeachers] = useState<TeacherClassAssignmentRow[]>([]) const [teachers, setTeachers] = useState<TeacherClassAssignmentRow[]>([])
const [classes, setClasses] = useState<TeacherClassOption[]>([]) const [classes, setClasses] = useState<TeacherClassOption[]>([])
const [selectedTeacherId, setSelectedTeacherId] = useState<number | null>(null) const [selectedTeacherId, setSelectedTeacherId] = useState<number | null>(null)
@@ -4696,7 +4874,7 @@ export function AdminTeacherClassAssignmentPage() {
<AdminParityShell title="Teacher Class Assignments"> <AdminParityShell title="Teacher Class Assignments">
{message ? <div className="alert alert-info">{message}</div> : null} {message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-3 mb-4"> <div className="row g-3 mb-4">
<div className="col-lg-3"><label className="form-label">School Year</label><div className="d-flex gap-2"><input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} /><button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button></div></div> <div className="col-lg-3"><CiAcademicFilter selectedYear={schoolYear} onApply={({ schoolYear: year }) => void load(year)} /></div>
<div className="col-lg-3"><label className="form-label">Teacher</label><select className="form-select" value={selectedTeacherId ?? ''} onChange={(e) => { const id = e.target.value ? Number(e.target.value) : null; setSelectedTeacherId(id); const teacher = teachers.find((row) => row.teacher_id === id); setSelectedRole(String(teacher?.role ?? '')) }}><option value="">Select teacher</option>{teachers.map((teacher) => <option key={teacher.teacher_id} value={teacher.teacher_id}>{teacher.name ?? `${teacher.firstname ?? ''} ${teacher.lastname ?? ''}`.trim()}</option>)}</select></div> <div className="col-lg-3"><label className="form-label">Teacher</label><select className="form-select" value={selectedTeacherId ?? ''} onChange={(e) => { const id = e.target.value ? Number(e.target.value) : null; setSelectedTeacherId(id); const teacher = teachers.find((row) => row.teacher_id === id); setSelectedRole(String(teacher?.role ?? '')) }}><option value="">Select teacher</option>{teachers.map((teacher) => <option key={teacher.teacher_id} value={teacher.teacher_id}>{teacher.name ?? `${teacher.firstname ?? ''} ${teacher.lastname ?? ''}`.trim()}</option>)}</select></div>
<div className="col-lg-4"><label className="form-label">Class</label><select className="form-select" value={selectedClassId ?? ''} onChange={(e) => setSelectedClassId(e.target.value ? Number(e.target.value) : null)}><option value="">Select class</option>{classes.map((classRow) => <option key={classRow.class_section_id} value={classRow.class_section_id}>{classRow.class_section_name}</option>)}</select></div> <div className="col-lg-4"><label className="form-label">Class</label><select className="form-select" value={selectedClassId ?? ''} onChange={(e) => setSelectedClassId(e.target.value ? Number(e.target.value) : null)}><option value="">Select class</option>{classes.map((classRow) => <option key={classRow.class_section_id} value={classRow.class_section_id}>{classRow.class_section_name}</option>)}</select></div>
<div className="col-lg-2 d-grid"><button className="btn btn-primary align-self-end" type="button" onClick={() => void submitAssignment()} disabled={!selectedTeacherId || !selectedClassId}>Assign Class</button></div> <div className="col-lg-2 d-grid"><button className="btn btn-primary align-self-end" type="button" onClick={() => void submitAssignment()} disabled={!selectedTeacherId || !selectedClassId}>Assign Class</button></div>
@@ -4742,7 +4920,7 @@ export function AdminTeacherClassAssignmentPage() {
} }
export function AdminTeacherSubmissionsPage() { export function AdminTeacherSubmissionsPage() {
const [searchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([]) const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({}) const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
const [selected, setSelected] = useState<Record<string, boolean>>({}) const [selected, setSelected] = useState<Record<string, boolean>>({})
@@ -4794,6 +4972,18 @@ export function AdminTeacherSubmissionsPage() {
<AdminParityShell title="Teacher Submission Dashboard"> <AdminParityShell title="Teacher Submission Dashboard">
{message ? <div className="alert alert-info">{message}</div> : null} {message ? <div className="alert alert-info">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<CiAcademicFilter
selectedYear={searchParams.get('school_year') ?? schoolYear}
selectedSemester={searchParams.get('semester') ?? semester}
onApply={({ schoolYear: year, semester: sem }) => {
const next = new URLSearchParams(searchParams)
if (year) next.set('school_year', year)
else next.delete('school_year')
if (sem) next.set('semester', sem)
else next.delete('semester')
setSearchParams(next)
}}
/>
<div className="row g-3 mb-4"> <div className="row g-3 mb-4">
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Completion</div><div className="fs-3 fw-semibold">{summary.submission_percentage ?? 0}%</div></div></div></div> <div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Completion</div><div className="fs-3 fw-semibold">{summary.submission_percentage ?? 0}%</div></div></div></div>
<div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Missing Items</div><div className="fs-3 fw-semibold">{summary.missing_items ?? 0}</div></div></div></div> <div className="col-sm-6 col-xl-3"><div className="card shadow-sm"><div className="card-body"><div className="text-uppercase small text-muted">Missing Items</div><div className="fs-3 fw-semibold">{summary.missing_items ?? 0}</div></div></div></div>
+5 -5
View File
@@ -12,8 +12,8 @@ export function LoginPage() {
const locState = location.state as { from?: string; registered?: boolean; message?: string } | null const locState = location.state as { from?: string; registered?: boolean; message?: string } | null
const from = locState?.from ?? '/app/home' const from = locState?.from ?? '/app/home'
const flashSuccess = const flashMessage = locState?.message ?? null
locState?.registered === true ? locState?.message ?? 'Registration submitted.' : null const flashVariant = locState?.registered === true ? 'success' : 'warning'
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -101,9 +101,9 @@ export function LoginPage() {
Login to Your Account Login to Your Account
</h3> </h3>
{flashSuccess ? ( {flashMessage ? (
<div className="alert alert-success text-center" role="alert"> <div className={`alert alert-${flashVariant} text-center`} role="alert">
{flashSuccess} {flashMessage}
</div> </div>
) : null} ) : null}
+72 -33
View File
@@ -20,6 +20,59 @@ type NavFormState = {
roles: string[] roles: string[]
} }
const navBuilderCollator = new Intl.Collator(undefined, {
sensitivity: 'base',
numeric: true,
})
function compareLabels(a: string | null | undefined, b: string | null | undefined): number {
return navBuilderCollator.compare(a?.trim() || '', b?.trim() || '')
}
function navParentId(item: NavBuilderItem): number | null {
return item.menu_parent_id ?? item.parent_id ?? null
}
function compareBuilderItems(a: NavBuilderItem, b: NavBuilderItem): number {
const parentResult = compareLabels(a.parent_label ?? '', b.parent_label ?? '')
const labelResult = compareLabels(a.label, b.label)
return parentResult || labelResult || a.id - b.id
}
function sortBuilderItems(items: NavBuilderItem[]): NavBuilderItem[] {
return [...items].sort(compareBuilderItems)
}
function sortParentOptions(options: NavBuilderData['parentOptions']): NavBuilderData['parentOptions'] {
return [...options].sort((a, b) => compareLabels(a.label, b.label) || a.id - b.id)
}
function normalizeBuilderData(data: NavBuilderData): NavBuilderData {
return {
...data,
items: sortBuilderItems(data.items ?? []),
parentOptions: sortParentOptions(data.parentOptions ?? []),
}
}
function alphabeticalOrdersByParent(items: NavBuilderItem[]): Record<string, number> {
const groups = new Map<string, NavBuilderItem[]>()
for (const item of items) {
const key = String(navParentId(item) ?? 'root')
groups.set(key, [...(groups.get(key) ?? []), item])
}
const orders: Record<string, number> = {}
for (const group of groups.values()) {
const sorted = [...group].sort((a, b) => compareLabels(a.label, b.label) || a.id - b.id)
sorted.forEach((item, index) => {
orders[String(item.id)] = index + 1
})
}
return orders
}
const blankForm: NavFormState = { const blankForm: NavFormState = {
id: '', id: '',
menu_parent_id: '', menu_parent_id: '',
@@ -47,7 +100,7 @@ export function NavBuilderPage() {
setLoading(true) setLoading(true)
try { try {
const res = await fetchNavBuilderData() const res = await fetchNavBuilderData()
setPayload(res.data?.data ?? { items: [], roles: [], parentOptions: [] }) setPayload(normalizeBuilderData(res.data?.data ?? { items: [], roles: [], parentOptions: [] }))
setMessages([]) setMessages([])
} catch (error) { } catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Failed to load menu items.' }]) setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Failed to load menu items.' }])
@@ -60,8 +113,10 @@ export function NavBuilderPage() {
void load() void load()
}, []) }, [])
const sortedItems = useMemo(() => sortBuilderItems(payload.items), [payload.items])
const filteredParentOptions = useMemo( const filteredParentOptions = useMemo(
() => payload.parentOptions.filter((option) => String(option.id) !== form.id), () => sortParentOptions(payload.parentOptions.filter((option) => String(option.id) !== form.id)),
[form.id, payload.parentOptions], [form.id, payload.parentOptions],
) )
@@ -77,7 +132,7 @@ export function NavBuilderPage() {
url: form.url.trim() || null, url: form.url.trim() || null,
icon_class: form.icon_class.trim() || null, icon_class: form.icon_class.trim() || null,
target: form.target.trim() || null, target: form.target.trim() || null,
sort_order: Number(form.sort_order) || 0, sort_order: 0,
is_enabled: form.is_enabled, is_enabled: form.is_enabled,
roles: form.roles.map(Number).filter((id) => id > 0), roles: form.roles.map(Number).filter((id) => id > 0),
}) })
@@ -105,13 +160,11 @@ export function NavBuilderPage() {
} }
async function onSaveOrder() { async function onSaveOrder() {
const orders = Object.fromEntries( const orders = alphabeticalOrdersByParent(payload.items)
payload.items.map((item) => [String(item.id), Number(item.sort_order ?? item.order ?? 0)]),
)
setMessages([]) setMessages([])
try { try {
await reorderNavBuilderItems(orders) await reorderNavBuilderItems(orders)
setMessages([{ type: 'success', message: 'Menu order saved.' }]) setMessages([{ type: 'success', message: 'Alphabetical menu order saved.' }])
await load() await load()
} catch (error) { } catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to reorder menu items.' }]) setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Unable to reorder menu items.' }])
@@ -133,6 +186,8 @@ export function NavBuilderPage() {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
const alphabeticalOrders = useMemo(() => alphabeticalOrdersByParent(sortedItems), [sortedItems])
const editingItem = form.id const editingItem = form.id
? payload.items.find((item) => String(item.id) === form.id) ?? null ? payload.items.find((item) => String(item.id) === form.id) ?? null
: null : null
@@ -154,7 +209,7 @@ export function NavBuilderPage() {
<div className="card-header d-flex justify-content-between align-items-center"> <div className="card-header d-flex justify-content-between align-items-center">
<span>Current Menu</span> <span>Current Menu</span>
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void onSaveOrder()}> <button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void onSaveOrder()}>
Save order Save alphabetical order
</button> </button>
</div> </div>
<div className="card-body"> <div className="card-body">
@@ -167,7 +222,7 @@ export function NavBuilderPage() {
<th>URL</th> <th>URL</th>
<th>Parent</th> <th>Parent</th>
<th>Roles</th> <th>Roles</th>
<th>Order</th> <th>Alphabetical position</th>
<th>Enabled</th> <th>Enabled</th>
<th>Depth</th> <th>Depth</th>
</tr> </tr>
@@ -182,7 +237,7 @@ export function NavBuilderPage() {
<td colSpan={8} className="text-center text-muted">No menu items found.</td> <td colSpan={8} className="text-center text-muted">No menu items found.</td>
</tr> </tr>
) : ( ) : (
payload.items.map((item) => ( sortedItems.map((item) => (
<tr key={item.id}> <tr key={item.id}>
<td className="text-nowrap"> <td className="text-nowrap">
<button className="btn btn-sm btn-outline-primary me-1" type="button" onClick={() => editItem(item)}> <button className="btn btn-sm btn-outline-primary me-1" type="button" onClick={() => editItem(item)}>
@@ -198,7 +253,7 @@ export function NavBuilderPage() {
{item.icon_class ? <code className="ms-2 small">{item.icon_class}</code> : null} {item.icon_class ? <code className="ms-2 small">{item.icon_class}</code> : null}
</td> </td>
<td>{item.url ? <code>{item.url}</code> : <span className="text-muted">-</span>}</td> <td>{item.url ? <code>{item.url}</code> : <span className="text-muted">-</span>}</td>
<td>{parentLabel(item, payload.items)}</td> <td>{parentLabel(item, sortedItems)}</td>
<td> <td>
{(item.roles?.names ?? []).length > 0 ? ( {(item.roles?.names ?? []).length > 0 ? (
item.roles?.names?.map((roleName) => ( item.roles?.names?.map((roleName) => (
@@ -211,21 +266,12 @@ export function NavBuilderPage() {
)} )}
</td> </td>
<td> <td>
<input <span className="badge bg-light text-dark">
className="form-control form-control-sm nav-builder-order" {alphabeticalOrders[String(item.id)] ?? '-'}
type="number" </span>
value={item.sort_order ?? item.order ?? 0}
onChange={(event) => {
const next = Number(event.target.value) || 0
setPayload((current) => ({
...current,
items: current.items.map((row) => (row.id === item.id ? { ...row, sort_order: next } : row)),
}))
}}
/>
</td> </td>
<td> <td>
{Boolean(item.is_enabled ?? item.enabled) ? ( {(item.is_enabled ?? item.enabled) ? (
<span className="badge bg-success">Yes</span> <span className="badge bg-success">Yes</span>
) : ( ) : (
<span className="badge bg-light text-dark">No</span> <span className="badge bg-light text-dark">No</span>
@@ -312,15 +358,8 @@ export function NavBuilderPage() {
</select> </select>
</div> </div>
<div className="col-md-4 mb-2"> <div className="col-md-4 mb-2">
<label className="form-label" htmlFor="sort_order">Order</label> <label className="form-label">Order</label>
<input <div className="form-control bg-light text-muted">Alphabetical</div>
className="form-control"
type="number"
name="sort_order"
id="sort_order"
value={form.sort_order}
onChange={(event) => setForm((current) => ({ ...current, sort_order: event.target.value }))}
/>
</div> </div>
<div className="col-md-4 mb-2 d-flex align-items-end"> <div className="col-md-4 mb-2 d-flex align-items-end">
<div className="form-check"> <div className="form-check">
@@ -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>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
fetchAdministratorAdminAttendance, fetchAdministratorAdminAttendance,
saveAdministratorAdminAttendance, saveAdministratorAdminAttendance,
@@ -26,6 +27,9 @@ export function AdminsAttendanceFormPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -143,13 +147,13 @@ export function AdminsAttendanceFormPage() {
> >
<div className="col-sm-3"> <div className="col-sm-3">
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<input <select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
type="text" {(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
name="school_year" <option key={option.value} value={option.value}>
className="form-control" {option.label}
defaultValue={schoolYear} </option>
placeholder="e.g. 2025-2026" ))}
/> </select>
</div> </div>
<div className="col-sm-2"> <div className="col-sm-2">
<label className="form-label">Semester</label> <label className="form-label">Semester</label>
@@ -0,0 +1,283 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import {
completeAttendanceFollowUp,
fetchAttendanceManagementDashboard,
recordAttendanceBadgeScan,
recordManualAttendanceEntry,
reprintAttendanceLateSlip,
type AttendanceManagementDashboard,
type AttendanceManagementEvent,
} from '../../api/attendanceManagement'
import { ApiHttpError } from '../../api/http'
type FormState = {
badge_scan: string
person_name: string
person_type: string
role_grade: string
entry_time: string
manual_reason: string
report_status: string
reason: string
}
const today = new Date().toISOString().slice(0, 10)
const emptyForm: FormState = {
badge_scan: '',
person_name: '',
person_type: 'student',
role_grade: '',
entry_time: '',
manual_reason: 'badge missing',
report_status: 'pending_clarification',
reason: '',
}
function nice(value: string | null | undefined): string {
if (!value) return '—'
return value.replace(/_/g, ' ')
}
function boolish(value: boolean | number): boolean {
return value === true || value === 1
}
export function AttendanceManagementPage() {
const [date, setDate] = useState(today)
const [status, setStatus] = useState('')
const [reportStatus, setReportStatus] = useState('')
const [q, setQ] = useState('')
const [dashboard, setDashboard] = useState<AttendanceManagementDashboard | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const params = useMemo(() => ({ date, attendance_status: status, report_status: reportStatus, q }), [date, status, reportStatus, q])
async function load() {
setLoading(true)
setError(null)
try {
setDashboard(await fetchAttendanceManagementDashboard(params))
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load attendance management dashboard.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [params])
async function submitScan(event: FormEvent) {
event.preventDefault()
setMessage(null)
setError(null)
try {
await recordAttendanceBadgeScan({
badge_scan: form.badge_scan,
scan_type: 'entry',
report_status: form.report_status,
reason: form.reason,
})
setMessage('Badge scan classified. Late students were automatically added to late-slip workflow.')
setForm((f) => ({ ...f, badge_scan: '', reason: '' }))
await load()
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to record badge scan.')
}
}
async function submitManual(event: FormEvent) {
event.preventDefault()
setMessage(null)
setError(null)
try {
await recordManualAttendanceEntry({
person_type: form.person_type,
person_name: form.person_name,
role_grade: form.role_grade,
entry_time: form.entry_time || undefined,
manual_reason: form.manual_reason,
report_status: form.report_status,
reason: form.reason,
})
setMessage('Manual entry recorded and counted as a badge exception. Yes, accountability survived another checkbox.')
setForm(emptyForm)
await load()
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to record manual entry.')
}
}
async function complete(row: AttendanceManagementEvent) {
setMessage(null)
setError(null)
try {
await completeAttendanceFollowUp(row.id, {
report_status: row.report_status === 'not_reported' ? 'pending_clarification' : row.report_status,
final_decision: row.final_decision || 'Resolved after contact attempt',
notes: row.notes || 'Follow-up completed from management dashboard.',
})
setMessage('Follow-up marked completed.')
await load()
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to complete follow-up.')
}
}
async function reprint(row: AttendanceManagementEvent) {
setMessage(null)
setError(null)
try {
await reprintAttendanceLateSlip(row.id, { reason: 'Dashboard reprint' })
setMessage('Late slip reprint logged.')
await load()
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to log late-slip reprint.')
}
}
const summary = dashboard?.summary
return (
<div className="container-fluid py-4">
<div className="d-flex flex-wrap justify-content-between gap-3 align-items-start mb-4">
<div>
<h1 className="h3 mb-1">School Attendance Management</h1>
<p className="text-muted mb-0">Badge scans, manual entries, not-reported follow-up, late slips, early dismissal risk, and badge exceptions in one place.</p>
</div>
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-outline-primary" to="/app/administrator/attendance/badge-scans">
Badge Scanning List
</Link>
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()} disabled={loading}>Refresh</button>
</div>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="row g-3 mb-4">
{summary ? Object.entries(summary).map(([key, value]) => (
<div className="col-6 col-md-3 col-xl-2" key={key}>
<div className="card h-100 shadow-sm">
<div className="card-body">
<div className="small text-muted text-uppercase">{nice(key)}</div>
<div className="display-6">{value}</div>
</div>
</div>
</div>
)) : null}
</div>
<div className="card mb-4 shadow-sm">
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-md-3">
<label className="form-label">Date</label>
<input className="form-control" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="col-md-3">
<label className="form-label">Attendance status</label>
<select className="form-select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All</option>
{(dashboard?.filters.attendance_status ?? []).map((x) => <option key={x} value={x}>{nice(x)}</option>)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Report status</label>
<select className="form-select" value={reportStatus} onChange={(e) => setReportStatus(e.target.value)}>
<option value="">All</option>
{(dashboard?.filters.report_status ?? []).map((x) => <option key={x} value={x}>{nice(x)}</option>)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Search</label>
<input className="form-control" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, badge, grade, combination" />
</div>
</div>
</div>
</div>
<div className="row g-4 mb-4">
<div className="col-lg-6">
<form className="card shadow-sm h-100" onSubmit={submitScan}>
<div className="card-body">
<h2 className="h5">Badge entry scan</h2>
<p className="text-muted small">Classifies present/late and creates late-slip records for late students.</p>
<label className="form-label">Badge scan</label>
<input className="form-control mb-3" value={form.badge_scan} onChange={(e) => setForm({ ...form, badge_scan: e.target.value })} required />
<label className="form-label">Report status</label>
<select className="form-select mb-3" value={form.report_status} onChange={(e) => setForm({ ...form, report_status: e.target.value })}>
<option value="reported">Reported</option>
<option value="not_reported">Not reported</option>
<option value="pending_clarification">Pending clarification</option>
</select>
<label className="form-label">Reason</label>
<input className="form-control mb-3" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} />
<button className="btn btn-primary" type="submit">Record badge scan</button>
</div>
</form>
</div>
<div className="col-lg-6">
<form className="card shadow-sm h-100" onSubmit={submitManual}>
<div className="card-body">
<h2 className="h5">Manual verified entry</h2>
<p className="text-muted small">Used for missing, lost, damaged, not-issued badges, or scanner failure.</p>
<div className="row g-3">
<div className="col-md-6"><label className="form-label">Name</label><input className="form-control" value={form.person_name} onChange={(e) => setForm({ ...form, person_name: e.target.value })} required /></div>
<div className="col-md-3"><label className="form-label">Type</label><select className="form-select" value={form.person_type} onChange={(e) => setForm({ ...form, person_type: e.target.value })}><option value="student">Student</option><option value="staff">Staff</option><option value="contractor">Contractor</option><option value="visitor">Visitor</option></select></div>
<div className="col-md-3"><label className="form-label">Grade/Dept</label><input className="form-control" value={form.role_grade} onChange={(e) => setForm({ ...form, role_grade: e.target.value })} /></div>
<div className="col-md-6"><label className="form-label">Entry time</label><input className="form-control" type="datetime-local" value={form.entry_time} onChange={(e) => setForm({ ...form, entry_time: e.target.value })} /></div>
<div className="col-md-6"><label className="form-label">Manual reason</label><input className="form-control" value={form.manual_reason} onChange={(e) => setForm({ ...form, manual_reason: e.target.value })} /></div>
<div className="col-md-6"><label className="form-label">Report status</label><select className="form-select" value={form.report_status} onChange={(e) => setForm({ ...form, report_status: e.target.value })}><option value="reported">Reported</option><option value="not_reported">Not reported</option><option value="pending_clarification">Pending clarification</option></select></div>
<div className="col-md-6"><label className="form-label">Reason</label><input className="form-control" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} /></div>
</div>
<button className="btn btn-primary mt-3" type="submit">Record manual entry</button>
</div>
</form>
</div>
</div>
<div className="card shadow-sm">
<div className="card-body">
<h2 className="h5">Admin follow-up dashboard</h2>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th>Name</th><th>Role/Grade</th><th>Status</th><th>Scan/manual time</th><th>Reported</th><th>Counts</th><th>Combination</th><th>Action</th><th>Decision</th><th></th>
</tr>
</thead>
<tbody>
{(dashboard?.rows ?? []).map((row) => (
<tr key={row.id} className={boolish(row.follow_up_required) && !boolish(row.follow_up_completed) ? 'table-warning' : ''}>
<td>{row.person_name || '—'}</td>
<td>{row.role_grade || row.person_type}</td>
<td>{nice(row.attendance_status)}<div className="small text-muted">Risk: {nice(row.risk_level)}</div></td>
<td>{row.official_entry_time || row.official_exit_time || '—'}<div className="small text-muted">{nice(row.entry_method || row.exit_method)}</div></td>
<td>{nice(row.report_status)}</td>
<td>{row.absence_count}ABS / {row.late_count}Late / {row.early_dismissal_count}ED / {row.badge_exception_count}Badge</td>
<td>{row.combination_code || 'Clear'}</td>
<td>{row.action_needed || 'Record and monitor'}</td>
<td>{row.final_decision || '—'}</td>
<td className="text-nowrap">
{boolish(row.follow_up_required) && !boolish(row.follow_up_completed) ? <button className="btn btn-sm btn-outline-success me-2" onClick={() => void complete(row)}>Complete</button> : null}
{row.attendance_status === 'late' ? <button className="btn btn-sm btn-outline-secondary" onClick={() => void reprint(row)}>Reprint slip</button> : null}
</td>
</tr>
))}
{!loading && (dashboard?.rows.length ?? 0) === 0 ? <tr><td colSpan={10} className="text-center text-muted py-4">No attendance management rows for these filters.</td></tr> : null}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
@@ -1,95 +1,11 @@
import { type FormEvent, useMemo } from 'react' import { Navigate, useSearchParams } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom' import { ATTENDANCE_VIOLATIONS_PENDING_PATH } from './attendancePaths'
import {
ATTENDANCE_VIOLATIONS_NOTIFIED_PATH,
ATTENDANCE_VIOLATIONS_PENDING_PATH,
} from './attendancePaths'
/** Landing screen for attendance violations — links to pending / notified lists (same APIs as subpages). */ /** Legacy `/attendance/violations` opened the pending list directly. Keep this route as a query-preserving alias. */
export function AttendanceViolationsHubPage() { export function AttendanceViolationsHubPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? '' const query = searchParams.toString()
const semester = searchParams.get('semester') ?? '' const to = query ? `${ATTENDANCE_VIOLATIONS_PENDING_PATH}?${query}` : ATTENDANCE_VIOLATIONS_PENDING_PATH
const filterQuery = useMemo(() => { return <Navigate to={to} replace />
const s = searchParams.toString()
return s ? `?${s}` : ''
}, [searchParams])
function applyFilter(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const p = new URLSearchParams()
const sy = String(fd.get('school_year') ?? '')
const sem = String(fd.get('semester') ?? '')
if (sy) p.set('school_year', sy)
if (sem) p.set('semester', sem)
setSearchParams(p)
}
return (
<div className="container-fluid px-0">
<h2 className="text-center mt-4 mb-3">Attendance Violations</h2>
<p className="text-center text-muted small mb-4">
Review students who have crossed absence or late thresholds. Data loads on each list via the API.
</p>
<div className="d-flex justify-content-center mb-4">
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto">
<label htmlFor="hub-school-year" className="form-label small mb-0">School Year</label>
<input id="hub-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
</div>
<div className="col-auto">
<label htmlFor="hub-semester" className="form-label small mb-0">Semester</label>
<select id="hub-semester" name="semester" className="form-select form-select-sm" defaultValue={semester}>
<option value=""> All </option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="col-auto">
<button type="submit" className="btn btn-sm btn-secondary">
Apply
</button>
</div>
</form>
</div>
<div className="row g-3 justify-content-center px-2 pb-4">
<div className="col-12 col-md-5">
<div className="card border-0 rounded-3 shadow-sm h-100">
<div className="card-body d-flex flex-column">
<h5 className="card-title">Pending</h5>
<p className="card-text small text-muted flex-grow-1">
Students who need notification or follow-up per policy (team notify, auto email, etc.).
</p>
<Link
className="btn btn-primary"
to={`${ATTENDANCE_VIOLATIONS_PENDING_PATH}${filterQuery}`}
>
Open pending list
</Link>
</div>
</div>
</div>
<div className="col-12 col-md-5">
<div className="card border-0 rounded-3 shadow-sm h-100">
<div className="card-body d-flex flex-column">
<h5 className="card-title">Notified</h5>
<p className="card-text small text-muted flex-grow-1">
Students whose parents were already notified; add or edit notes as needed.
</p>
<Link
className="btn btn-outline-primary"
to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`}
>
Open notified list
</Link>
</div>
</div>
</div>
</div>
</div>
)
} }
@@ -0,0 +1,203 @@
import { useEffect, useMemo, useState } from 'react'
import {
fetchBadgeScanningList,
type AttendanceManagementDashboard,
} from '../../api/attendanceManagement'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
const today = new Date().toISOString().slice(0, 10)
function nice(value: string | null | undefined): string {
if (!value) return '—'
return value.replace(/_/g, ' ')
}
export function BadgeScanningListPage() {
const { options, selectedYear } = useSchoolYearOptions({})
const [schoolYear, setSchoolYear] = useState(selectedYear)
const [date, setDate] = useState(today)
const [status, setStatus] = useState('')
const [q, setQ] = useState('')
const [dashboard, setDashboard] = useState<AttendanceManagementDashboard | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const params = useMemo(
() => ({
school_year: schoolYear,
date,
attendance_status: status,
q,
}),
[schoolYear, date, status, q],
)
useEffect(() => {
if (!schoolYear && selectedYear) setSchoolYear(selectedYear)
}, [schoolYear, selectedYear])
async function load() {
setLoading(true)
setError(null)
try {
setDashboard(await fetchBadgeScanningList(params))
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load badge scanning list.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [params])
const rows = dashboard?.rows ?? []
return (
<div className="container-fluid py-4">
<div className="d-flex flex-wrap justify-content-between gap-3 align-items-start mb-4">
<div>
<h1 className="h3 mb-1">Badge Scanning List</h1>
<p className="text-muted mb-0">
Students, staff, contractors, and visitors recorded through badge scan.
</p>
</div>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => void load()}
disabled={loading}
>
Refresh
</button>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card shadow-sm mb-4">
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-md-3">
<label className="form-label">School Year</label>
<select className="form-select" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)}>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Date</label>
<input
className="form-control"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="col-md-2">
<label className="form-label">Status</label>
<select
className="form-select"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="">All</option>
<option value="present">Present</option>
<option value="late">Late</option>
<option value="early_dismissal">Early dismissal</option>
</select>
</div>
<div className="col-md-3">
<label className="form-label">Search</label>
<input
className="form-control"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Name, badge, grade, department"
/>
</div>
<div className="col-md-1">
<button
className="btn btn-primary w-100"
type="button"
onClick={() => void load()}
disabled={loading}
>
Search
</button>
</div>
</div>
</div>
</div>
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h2 className="h5 mb-0">Scanned Badge Records</h2>
<span className="badge bg-secondary">{rows.length} records</span>
</div>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Grade/Dept</th>
<th>Badge ID</th>
<th>Date</th>
<th>Scan Time</th>
<th>Location</th>
<th>Status</th>
<th>Reported</th>
<th>Decision</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.person_name || '—'}</td>
<td>{nice(row.person_type)}</td>
<td>{row.role_grade || '—'}</td>
<td>{row.badge_id || '—'}</td>
<td>{row.event_date}</td>
<td>{row.official_entry_time || row.official_exit_time || '—'}</td>
<td>{row.scan_location || row.exit_location || '—'}</td>
<td>{nice(row.attendance_status)}</td>
<td>{nice(row.report_status)}</td>
<td>{row.final_decision || '—'}</td>
</tr>
))}
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={10} className="text-center text-muted py-4">
No badge scans found for this filter.
</td>
</tr>
) : null}
{loading ? (
<tr>
<td colSpan={10} className="text-center text-muted py-4">
Loading badge scans...
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
+11 -7
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { fetchEarlyDismissals, uploadEarlyDismissalSignature } from '../../api/session' import { fetchEarlyDismissals, uploadEarlyDismissalSignature } from '../../api/session'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { apiUrl } from '../../lib/apiOrigin' import { apiUrl } from '../../lib/apiOrigin'
import { EARLY_DISMISSALS_NEW_PATH, EARLY_DISMISSALS_PATH } from './attendancePaths' import { EARLY_DISMISSALS_NEW_PATH, EARLY_DISMISSALS_PATH } from './attendancePaths'
@@ -20,6 +21,9 @@ export function EarlyDismissalsPage() {
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 [flash, setFlash] = useState<string | null>(null) const [flash, setFlash] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -90,13 +94,13 @@ export function EarlyDismissalsPage() {
> >
<div className="col-md-4"> <div className="col-md-4">
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<input <select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
type="text" {(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
name="school_year" <option key={option.value} value={option.value}>
className="form-control" {option.label}
defaultValue={schoolYear} </option>
placeholder="e.g. 2025-2026" ))}
/> </select>
</div> </div>
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">Semester</label> <label className="form-label">Semester</label>
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchParentAttendanceReportsAdmin } from '../../api/session' import { fetchParentAttendanceReportsAdmin } from '../../api/session'
import type { ParentAttendanceAdminReportRow } from '../../api/types' import type { ParentAttendanceAdminReportRow } from '../../api/types'
@@ -14,6 +15,9 @@ export function ParentAttendanceReportsAdminPage() {
const [rows, setRows] = useState<ParentAttendanceAdminReportRow[]>([]) const [rows, setRows] = useState<ParentAttendanceAdminReportRow[]>([])
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 { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -63,13 +67,13 @@ export function ParentAttendanceReportsAdminPage() {
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={apply}> <form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={apply}>
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<input <select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
type="text" {(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
name="school_year" <option key={option.value} value={option.value}>
className="form-control" {option.label}
defaultValue={schoolYear} </option>
placeholder="e.g. 2025-2026" ))}
/> </select>
</div> </div>
<div className="col-md-2"> <div className="col-md-2">
<label className="form-label">Semester</label> <label className="form-label">Semester</label>
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session' import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session'
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths' import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
import type { TeacherStaffAttendanceRow } from '../../api/types' import type { TeacherStaffAttendanceRow } from '../../api/types'
@@ -26,6 +27,9 @@ export function TeacherAttendanceFormPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -136,13 +140,13 @@ export function TeacherAttendanceFormPage() {
<form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}> <form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}>
<div className="col-sm-3"> <div className="col-sm-3">
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<input <select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
type="text" {(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
name="school_year" <option key={option.value} value={option.value}>
className="form-control" {option.label}
defaultValue={schoolYear} </option>
placeholder="e.g. 2025-2026" ))}
/> </select>
</div> </div>
<div className="col-sm-2"> <div className="col-sm-2">
<label className="form-label">Semester</label> <label className="form-label">Semester</label>
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { apiUrl } from '../../lib/apiOrigin' import { apiUrl } from '../../lib/apiOrigin'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
fetchStaffMonthlyAttendanceOverview, fetchStaffMonthlyAttendanceOverview,
saveStaffMonthlyAttendanceCell, saveStaffMonthlyAttendanceCell,
@@ -66,7 +67,15 @@ export function TeacherAttendanceMonthPage() {
semester: semesterParam === '---' ? null : semesterParam, semester: semesterParam === '---' ? null : semesterParam,
schoolYear: schoolYearParam || null, schoolYear: schoolYearParam || null,
}) })
if (!cancelled) setPayload(data) if (!cancelled) {
setPayload(data)
if (!schoolYearParam && data.filters?.school_year) {
const next = new URLSearchParams(searchParams)
next.set('school_year', data.filters.school_year)
if (!next.get('semester')) next.set('semester', semesterParam)
setSearchParams(next, { replace: true })
}
}
} catch (e) { } catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load monthly attendance.') if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load monthly attendance.')
} finally { } finally {
@@ -96,6 +105,10 @@ export function TeacherAttendanceMonthPage() {
if (ys.length === 0 && schoolYearParam) ys.push(schoolYearParam) if (ys.length === 0 && schoolYearParam) ys.push(schoolYearParam)
return ys return ys
}, [payload?.schoolYears, schoolYearParam]) }, [payload?.schoolYears, schoolYearParam])
const { options } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYearParam,
})
const hasTeacherSections = (payload?.sections?.length ?? 0) > 0 const hasTeacherSections = (payload?.sections?.length ?? 0) > 0
const hasAdmins = (payload?.admins?.length ?? 0) > 0 const hasAdmins = (payload?.admins?.length ?? 0) > 0
@@ -194,9 +207,9 @@ export function TeacherAttendanceMonthPage() {
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<select name="school_year" className="form-select" defaultValue={schoolYearParam}> <select name="school_year" className="form-select" defaultValue={schoolYearParam}>
{schoolYears.map((y) => ( {options.map((option) => (
<option key={y} value={y}> <option key={option.value} value={option.value}>
{y} {option.label}
</option> </option>
))} ))}
</select> </select>
+31 -2
View File
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
fetchAttendanceViolationsNotified, fetchAttendanceViolationsNotified,
fetchAttendanceViolationsPending, fetchAttendanceViolationsPending,
@@ -29,6 +30,9 @@ export function ViolationsPendingPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? '' const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? '' const semester = searchParams.get('semester') ?? ''
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
const filterQuery = useMemo(() => { const filterQuery = useMemo(() => {
const s = searchParams.toString() const s = searchParams.toString()
@@ -94,7 +98,18 @@ export function ViolationsPendingPage() {
<form className="row g-2 align-items-end" onSubmit={applyFilter}> <form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto"> <div className="col-auto">
<label htmlFor="pending-school-year" className="form-label small mb-0">School Year</label> <label htmlFor="pending-school-year" className="form-label small mb-0">School Year</label>
<input id="pending-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} /> <select
id="pending-school-year"
name="school_year"
className="form-select form-select-sm"
defaultValue={schoolYear || selectedYear}
>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div> </div>
<div className="col-auto"> <div className="col-auto">
<label htmlFor="pending-semester" className="form-label small mb-0">Semester</label> <label htmlFor="pending-semester" className="form-label small mb-0">Semester</label>
@@ -315,6 +330,9 @@ export function ViolationsNotifiedPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? '' const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? '' const semester = searchParams.get('semester') ?? ''
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
const filterQuery = useMemo(() => { const filterQuery = useMemo(() => {
const s = searchParams.toString() const s = searchParams.toString()
@@ -399,7 +417,18 @@ export function ViolationsNotifiedPage() {
<form className="row g-2 align-items-end" onSubmit={applyFilter}> <form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto"> <div className="col-auto">
<label htmlFor="notified-school-year" className="form-label small mb-0">School Year</label> <label htmlFor="notified-school-year" className="form-label small mb-0">School Year</label>
<input id="notified-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} /> <select
id="notified-school-year"
name="school_year"
className="form-select form-select-sm"
defaultValue={schoolYear || selectedYear}
>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div> </div>
<div className="col-auto"> <div className="col-auto">
<label htmlFor="notified-semester" className="form-label small mb-0">Semester</label> <label htmlFor="notified-semester" className="form-label small mb-0">Semester</label>
+3
View File
@@ -12,3 +12,6 @@ export { AttendanceStudentViolationsViewPage } from './AttendanceStudentViolatio
export { AttendanceViolationsHubPage } from './AttendanceViolationsHubPage' export { AttendanceViolationsHubPage } from './AttendanceViolationsHubPage'
export { ViolationsNotifiedPage, ViolationsPendingPage } from './ViolationsPages' export { ViolationsNotifiedPage, ViolationsPendingPage } from './ViolationsPages'
export { AttendanceTemplatesIndexPage } from './AttendanceTemplatesIndexPage' export { AttendanceTemplatesIndexPage } from './AttendanceTemplatesIndexPage'
export { AttendanceManagementPage } from './AttendanceManagementPage'
export { BadgeScanningListPage } from './BadgeScanningListPage'
@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchCertificatesAuditLog, type CertificateAuditPayload } from '../../api/certificates'
function formatDateTime(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
function formatDate(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
}
export function CertificatesAuditLogPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const [data, setData] = useState<CertificateAuditPayload | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetchCertificatesAuditLog({ school_year: schoolYear || undefined })
.then((payload) => {
if (!cancelled) setData(payload)
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load certificate audit log.')
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [schoolYear])
function onYearChange(nextYear: string) {
const next = new URLSearchParams(searchParams)
if (nextYear) next.set('school_year', nextYear)
else next.delete('school_year')
setSearchParams(next, { replace: true })
}
return (
<div className="container-fluid py-4">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 className="mb-0">
<i className="bi bi-journal-check me-2" />
Certificate Audit Log
</h2>
<div className="text-muted small">
Every issued certificate is recorded here for tracking and auditing.
</div>
</div>
<Link to="/app/administrator/certificates" className="btn btn-outline-secondary btn-sm">
<i className="bi bi-award me-1" />
Generate Certificates
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <div className="text-muted">Loading audit log</div> : null}
{(data?.year_summary?.length ?? 0) > 0 ? (
<div className="row g-3 mb-4">
{data?.year_summary.map((row) => (
<div className="col-auto" key={row.school_year}>
<div className="card shadow-sm text-center px-4 py-2" style={{ minWidth: 150 }}>
<div className="fw-bold fs-4">{row.total}</div>
<div className="text-muted small">{row.school_year}</div>
</div>
</div>
))}
</div>
) : null}
<div className="card shadow-sm mb-4">
<div className="card-body py-2">
<div className="row g-2 align-items-center">
<div className="col-auto">
<label className="col-form-label fw-semibold">School Year</label>
</div>
<div className="col-auto">
<select
className="form-select form-select-sm"
value={schoolYear}
onChange={(event) => onYearChange(event.target.value)}
>
<option value=""> All years </option>
{(data?.year_summary ?? []).map((row) => (
<option key={row.school_year} value={row.school_year}>
{row.school_year} ({row.total})
</option>
))}
</select>
</div>
</div>
</div>
</div>
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<span className="fw-semibold">
Issued Certificates <span className="badge bg-secondary ms-1">{data?.records.length ?? 0}</span>
</span>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-hover table-striped align-middle mb-0 no-mgmt-sticky">
<thead className="table-light">
<tr>
<th>Certificate #</th>
<th>Student</th>
<th>Grade</th>
<th>Cert Date</th>
<th>School Year</th>
<th>Issued By</th>
<th>Issued At</th>
</tr>
</thead>
<tbody>
{(data?.records.length ?? 0) === 0 ? (
<tr>
<td colSpan={7} className="text-center text-muted py-4">
No certificates issued yet.
</td>
</tr>
) : (
data?.records.map((record) => (
<tr key={`${record.certificate_number}-${record.issued_at ?? ''}`}>
<td><code>{record.certificate_number}</code></td>
<td>{record.student_name}</td>
<td>{record.grade || '—'}</td>
<td>{formatDate(record.cert_date)}</td>
<td>{record.school_year || '—'}</td>
<td>{`${record.admin_firstname ?? ''} ${record.admin_lastname ?? ''}`.trim() || '—'}</td>
<td>{formatDateTime(record.issued_at)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
+374 -275
View File
@@ -1,320 +1,419 @@
import { type FormEvent, useEffect, useRef, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { import {
fetchCertFormOptions, fetchCertificateReprint,
fetchCertificatesDashboard,
postCertificateGenerate, postCertificateGenerate,
type CertClassSection, type CertificateDashboardPayload,
type CertStudent, type CertificateSectionRow,
type CertificateStudentRow,
} from '../../api/certificates' } from '../../api/certificates'
export function CertificatesPage() { function formatScore(value: number | null) {
const [searchParams, setSearchParams] = useSearchParams() return value == null ? '—' : value.toFixed(2)
const classId = searchParams.get('class_section_id') ?? '' }
const schoolYear = searchParams.get('school_year') ?? ''
const [classSections, setClassSections] = useState<CertClassSection[]>([]) function isoToday() {
const [students, setStudents] = useState<CertStudent[]>([]) return new Date().toISOString().slice(0, 10)
const [currentYear, setCurrentYear] = useState('') }
const [loadingStudents, setLoadingStudents] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()) function toDisplayDate(isoDate: string) {
const [certDate, setCertDate] = useState(() => { const date = new Date(`${isoDate}T00:00:00`)
const d = new Date() if (Number.isNaN(date.getTime())) return isoDate
return d.toISOString().slice(0, 10) return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${date.getFullYear()}`
}) }
const [generating, setGenerating] = useState(false)
const selectAllRef = useRef<HTMLInputElement>(null) function downloadBlob(blob: Blob) {
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener')
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
function DecisionBadge({ student }: { student: CertificateStudentRow }) {
const colorMap: Record<string, string> = {
Pass: 'success',
'Repeat Class': 'danger',
'Make-up exam in fall': 'info',
'Deferred decision': 'info',
Expel: 'danger',
Withdrawn: 'secondary',
}
if (student.decision_rows.length === 0 || student.decision_state === 'pending') {
return <span className="badge bg-warning text-dark">Pending</span>
}
if (student.decision_labels.length === 0) {
return <span className="text-muted small"></span>
}
return (
<>
{student.decision_labels.map((label) => (
<span key={label} className={`badge bg-${colorMap[label] ?? 'secondary'} me-1`}>
{label}
</span>
))}
</>
)
}
function StatusDot({ hasPass, fullyDone }: { hasPass: boolean; fullyDone: boolean }) {
const className = !hasPass ? 'no-eligible' : fullyDone ? 'done' : 'pending'
return <span className={`cert-status-dot ${className}`} aria-hidden />
}
function CertificateSectionCard({
section,
schoolYear,
defaultCertDate,
onGenerated,
}: {
section: CertificateSectionRow
schoolYear: string
defaultCertDate: string
onGenerated: () => Promise<void> | void
}) {
const eligibleStudents = useMemo(
() => section.students.filter((student) => student.eligible),
[section.students],
)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [certDate, setCertDate] = useState(isoToday())
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Load class sections on mount / school_year change
useEffect(() => { useEffect(() => {
fetchCertFormOptions({ school_year: schoolYear || undefined }) setSelectedIds([])
.then((raw: unknown) => { setCertDate(isoToday())
// eslint-disable-next-line @typescript-eslint/no-explicit-any setError(null)
const payload = (raw as any)?.data ?? raw }, [section.section_id, defaultCertDate])
setClassSections(Array.isArray(payload?.class_sections) ? payload.class_sections : [])
setCurrentYear(typeof payload?.school_year === 'string' ? payload.school_year : '')
})
.catch((err: unknown) => {
console.error('[Certificates] form-options error:', err)
if (err instanceof ApiHttpError) {
setError(`API error ${err.status}: ${err.message}`)
} else if (err instanceof Error) {
setError(`Error: ${err.message}`)
} else {
setError('Failed to load classes.')
}
})
}, [schoolYear])
// Load students when class changes const allSelected = eligibleStudents.length > 0 && selectedIds.length === eligibleStudents.length
useEffect(() => { const partiallySelected = selectedIds.length > 0 && !allSelected
if (!classId) {
setStudents([]) async function handleGenerate(event: FormEvent) {
setSelectedIds(new Set()) event.preventDefault()
if (selectedIds.length === 0) {
setError('Please select at least one student.')
return return
} }
setLoadingStudents(true)
setBusy(true)
setError(null) setError(null)
fetchCertFormOptions({ class_section_id: classId, school_year: schoolYear || undefined })
.then((raw: unknown) => { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const blob = await postCertificateGenerate({
const payload = (raw as any)?.data ?? raw student_ids: selectedIds,
setStudents(Array.isArray(payload?.students) ? payload.students : []) cert_date: toDisplayDate(certDate),
setSelectedIds(new Set()) class_section_id: section.section_id,
school_year: schoolYear,
}) })
.catch(() => setStudents([])) downloadBlob(blob)
.finally(() => setLoadingStudents(false)) await onGenerated()
}, [classId, schoolYear]) setSelectedIds([])
} catch (e) {
// Keep select-all checkbox tri-state in sync setError(e instanceof ApiHttpError ? e.message : 'Failed to generate certificates.')
useEffect(() => { } finally {
const el = selectAllRef.current setBusy(false)
if (!el) return
if (students.length === 0) {
el.checked = false
el.indeterminate = false
} else if (selectedIds.size === students.length) {
el.checked = true
el.indeterminate = false
} else if (selectedIds.size === 0) {
el.checked = false
el.indeterminate = false
} else {
el.checked = false
el.indeterminate = true
} }
}, [selectedIds, students])
function handleClassChange(value: string) {
const next = new URLSearchParams(searchParams)
if (value) next.set('class_section_id', value)
else next.delete('class_section_id')
setSearchParams(next, { replace: true })
} }
function handleSchoolYearChange(value: string) { async function handleReprint(certificateNumber: string) {
const next = new URLSearchParams(searchParams) setBusy(true)
if (value) next.set('school_year', value) setError(null)
else next.delete('school_year')
next.delete('class_section_id') try {
setSearchParams(next, { replace: true }) const blob = await fetchCertificateReprint(certificateNumber)
downloadBlob(blob)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to reprint certificate.')
} finally {
setBusy(false)
}
} }
function toggleStudent(id: number) { function toggleStudent(studentId: number) {
setSelectedIds((prev) => { setSelectedIds((previous) =>
const next = new Set(prev) previous.includes(studentId)
if (next.has(id)) next.delete(id) ? previous.filter((id) => id !== studentId)
else next.add(id) : [...previous, studentId],
return next )
})
} }
function toggleAll(checked: boolean) { function toggleAll(checked: boolean) {
if (checked) setSelectedIds(new Set(students.map((s) => s.id))) setSelectedIds(checked ? eligibleStudents.map((student) => student.student_id) : [])
else setSelectedIds(new Set())
} }
function formatCertDate(isoDate: string): string {
const d = new Date(isoDate + 'T00:00:00')
if (isNaN(d.getTime())) return isoDate
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}/${dd}/${d.getFullYear()}`
}
async function handleGenerate(e: FormEvent) {
e.preventDefault()
if (selectedIds.size === 0) return
setGenerating(true)
setError(null)
try {
const blob = await postCertificateGenerate({
student_ids: Array.from(selectedIds),
cert_date: formatCertDate(certDate),
class_section_id: classId ? Number(classId) : null,
})
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
} catch (err) {
setError(err instanceof ApiHttpError ? err.message : 'Failed to generate certificates.')
} finally {
setGenerating(false)
}
}
const hasStudents = students.length > 0
return ( return (
<div className="container-fluid py-4"> <form className="mb-5" onSubmit={handleGenerate}>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3"> <h4 className="mt-4 mb-2 text-center">{section.section_name}</h4>
<div>
<h2 className="mb-0">
<i className="bi bi-award me-2" />
Generate Certificates
</h2>
<div className="text-muted small">
Select a class, choose students, then generate and print their certificates.
</div>
</div>
</div>
{error && ( {error ? <div className="alert alert-danger py-2">{error}</div> : null}
<div className="alert alert-danger alert-dismissible fade show" role="alert">
{error} <div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<button <div className="d-flex align-items-center gap-4 flex-wrap">
type="button" <span className="fw-semibold">
className="btn-close" Students <span className="badge bg-secondary ms-1">{section.student_count}</span>
onClick={() => setError(null)} </span>
aria-label="Close" <span className="text-muted small">
<strong className="text-success">{section.pass_count}</strong> Pass
</span>
<span className="text-muted small">
<strong className="text-primary">{section.cert_count}</strong> Generated
</span>
<span className="text-muted small">
<strong className={section.remaining_count > 0 ? 'text-warning' : 'text-muted'}>
{section.remaining_count}
</strong>{' '}
Remaining
</span>
</div>
<div className="input-group input-group-sm" style={{ width: 200 }}>
<span className="input-group-text">
<i className="bi bi-calendar3" />
</span>
<input
type="date"
className="form-control"
value={certDate}
onChange={(event) => setCertDate(event.target.value)}
title="Certificate date"
/> />
</div> </div>
)}
{/* Filter */}
<div className="card shadow-sm mb-4">
<div className="card-header fw-semibold">Filter Students</div>
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-12 col-md-5">
<label className="form-label fw-semibold mb-1">Class / Section</label>
<select
className="form-select"
value={classId}
onChange={(e) => handleClassChange(e.target.value)}
>
<option value=""> Select a class </option>
{classSections.map((cs) => (
<option key={cs.class_section_id} value={String(cs.class_section_id)}>
{cs.class_section_name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-3">
<label className="form-label fw-semibold mb-1">School Year</label>
<input
type="text"
className="form-control"
placeholder="e.g. 2024-2025"
value={schoolYear || currentYear}
onChange={(e) => handleSchoolYearChange(e.target.value)}
/>
</div>
</div>
</div>
</div> </div>
{loadingStudents && ( <div className="table-responsive">
<div className="text-muted">Loading students</div> <table className="table table-hover table-striped align-middle mb-0">
)} <thead className="table-light">
<tr>
{!loadingStudents && hasStudents && ( <th style={{ width: 40 }} className="text-center">
<form onSubmit={handleGenerate}> <input
<div className="card shadow-sm mb-3"> className="form-check-input"
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> type="checkbox"
<span className="fw-semibold"> checked={allSelected}
Students ref={(node) => {
<span className="badge bg-secondary ms-1">{students.length}</span> if (node) node.indeterminate = partiallySelected
</span> }}
<div className="d-flex align-items-center gap-3"> onChange={(event) => toggleAll(event.target.checked)}
<div className="input-group input-group-sm" style={{ width: 200 }}> />
<span className="input-group-text"> </th>
<i className="bi bi-calendar3" /> <th>First Name</th>
</span> <th>Last Name</th>
<input <th className="text-center">Year Score</th>
type="date" <th>Decision</th>
className="form-control" <th>Certificate No.</th>
value={certDate} </tr>
onChange={(e) => setCertDate(e.target.value)} </thead>
title="Certificate date" <tbody>
/> {section.students.map((student) => (
</div> <tr key={student.student_id}>
<div className="form-check mb-0"> <td className="text-center">
<input <input
className="form-check-input" className="form-check-input"
type="checkbox" type="checkbox"
id="selectAll" checked={selectedIds.includes(student.student_id)}
ref={selectAllRef} disabled={!student.eligible || busy}
onChange={(e) => toggleAll(e.target.checked)} onChange={() => toggleStudent(student.student_id)}
/> />
<label className="form-check-label" htmlFor="selectAll"> </td>
Select all <td>{student.firstname}</td>
</label> <td>{student.lastname}</td>
</div> <td className="text-center fw-semibold">{formatScore(student.year_score)}</td>
</div> <td>
</div> <DecisionBadge student={student} />
</td>
<td>
{student.certificate_number ? (
<button
type="button"
className="btn btn-link btn-sm p-0 font-monospace"
disabled={busy}
onClick={() => handleReprint(student.certificate_number as string)}
>
{student.certificate_number}
</button>
) : (
<span className="text-muted"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card-body p-0"> <div className="d-flex justify-content-between align-items-center mt-2">
<div className="table-responsive"> <span className="text-muted small">
<table className="table table-hover table-striped align-middle mb-0"> {selectedIds.length} student{selectedIds.length !== 1 ? 's' : ''} selected
<thead className="table-light"> </span>
<tr> <button type="submit" className="btn btn-success" disabled={selectedIds.length === 0 || busy}>
<th style={{ width: 40 }} /> {busy ? (
<th>Last Name</th> <>
<th>First Name</th> <span className="spinner-border spinner-border-sm me-1" />
<th>Grade / Class</th> Generating...
</tr> </>
</thead> ) : (
<tbody> <>
{students.map((s) => ( <i className="bi bi-printer me-1" />
<tr key={s.id}> Generate & Print Certificates
<td className="text-center"> </>
<input )}
className="form-check-input" </button>
type="checkbox" </div>
checked={selectedIds.has(s.id)} </form>
onChange={() => toggleStudent(s.id)} )
/> }
</td>
<td>{s.lastname}</td>
<td>{s.firstname}</td>
<td>{s.grade}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card-footer d-flex justify-content-between align-items-center"> export function CertificatesPage() {
<span className="text-muted small"> const [searchParams, setSearchParams] = useSearchParams()
{selectedIds.size} student{selectedIds.size !== 1 ? 's' : ''} selected const schoolYear = searchParams.get('school_year') ?? ''
</span> const groupKey = searchParams.get('group') ?? ''
<button
type="submit" const [data, setData] = useState<CertificateDashboardPayload | null>(null)
className="btn btn-success" const [loading, setLoading] = useState(false)
disabled={selectedIds.size === 0 || generating} const [error, setError] = useState<string | null>(null)
> const [yearInput, setYearInput] = useState('')
{generating ? (
<> async function loadDashboard(currentSchoolYear: string) {
<span className="spinner-border spinner-border-sm me-1" role="status" /> setLoading(true)
Generating setError(null)
</>
) : ( try {
<> const payload = await fetchCertificatesDashboard({
<i className="bi bi-printer me-1" /> school_year: currentSchoolYear || undefined,
Generate &amp; Print Certificates })
</> setData(payload)
)} setYearInput(payload.school_year || currentSchoolYear)
</button> } catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load certificate dashboard.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadDashboard(schoolYear)
}, [schoolYear])
const groups = data?.grade_groups ?? []
const activeGroupKey =
(groupKey && groups.some((group) => group.key === groupKey) ? groupKey : '') ||
data?.default_group_key ||
groups[0]?.key ||
''
const activeGroup = groups.find((group) => group.key === activeGroupKey) ?? null
function applySchoolYear(event: FormEvent) {
event.preventDefault()
const next = new URLSearchParams(searchParams)
if (yearInput.trim()) next.set('school_year', yearInput.trim())
else next.delete('school_year')
next.delete('group')
setSearchParams(next, { replace: true })
}
function setActiveGroup(nextGroupKey: string) {
const next = new URLSearchParams(searchParams)
next.set('group', nextGroupKey)
if (schoolYear) next.set('school_year', schoolYear)
setSearchParams(next, { replace: true })
}
return (
<div className="container-fluid">
<div className="wrapper">
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-4 mb-3">
<div>
<h2 className="mb-1">
<i className="bi bi-award me-2" />
Generate Certificates
</h2>
<div className="text-muted small">
Students are eligible only when the saved final generated decision is Pass.
</div> </div>
</div> </div>
</form> <Link className="btn btn-outline-secondary btn-sm" to="/app/administrator/certificates/log">
)} <i className="bi bi-journal-check me-1" />
Audit Log
{!loadingStudents && classId && !hasStudents && ( </Link>
<div className="alert alert-warning">
No active students found for the selected class and school year.
</div> </div>
)}
{!loadingStudents && !classId && ( {error ? <div className="alert alert-danger">{error}</div> : null}
<div className="alert alert-info">
Select a class above to load its student roster. <div className="d-flex justify-content-end mb-3">
<form className="d-flex gap-2 align-items-center" onSubmit={applySchoolYear}>
<label className="form-label mb-0 me-1 text-muted small">School Year</label>
<input
type="text"
className="form-control form-control-sm"
style={{ width: 130 }}
value={yearInput}
onChange={(event) => setYearInput(event.target.value)}
placeholder="e.g. 2024-2025"
/>
<button type="submit" className="btn btn-sm btn-outline-primary">
<i className="bi bi-arrow-repeat me-1" />
Reload
</button>
</form>
</div> </div>
)}
{loading ? <div className="text-muted">Loading certificates</div> : null}
{!loading && groups.length === 0 ? (
<div className="alert alert-info">No classes found for {data?.school_year || schoolYear || 'this year'}.</div>
) : null}
{!loading && groups.length > 0 ? (
<>
<ul className="nav nav-tabs justify-content-center" style={{ flexWrap: 'wrap', rowGap: '.25rem' }}>
{groups.map((group) => (
<li className="nav-item" key={group.key}>
<button
type="button"
className={`nav-link ${group.key === activeGroupKey ? 'active' : ''}`}
onClick={() => setActiveGroup(group.key)}
>
<StatusDot hasPass={group.has_pass} fullyDone={group.fully_done} />
{group.label}
<span className="badge bg-secondary ms-1">{group.total}</span>
</button>
</li>
))}
</ul>
<div className="mt-3">
{activeGroup?.sections.map((section) => (
<CertificateSectionCard
key={section.section_id}
section={section}
schoolYear={data?.school_year ?? schoolYear}
defaultCertDate={data?.cert_date ?? ''}
onGenerated={() => loadDashboard(data?.school_year ?? schoolYear)}
/>
))}
</div>
</>
) : null}
</div>
<style>{`
.cert-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
flex-shrink: 0;
}
.cert-status-dot.done { background-color: #198754; }
.cert-status-dot.pending { background-color: #dc3545; }
.cert-status-dot.no-eligible { background-color: #adb5bd; }
`}</style>
</div> </div>
) )
} }
+11 -18
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session' import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session'
import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types' import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types'
@@ -9,14 +10,9 @@ function adjustQty(input: HTMLInputElement, delta: number) {
input.value = String(curr + delta) input.value = String(curr + delta)
} }
function defaultSchoolYear() {
const y = new Date().getFullYear()
return `${y}-${y + 1}`
}
export function ClassPrepListPage() { export function ClassPrepListPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? defaultSchoolYear() const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? '' const semester = searchParams.get('semester') ?? ''
const [data, setData] = useState<ClassPrepIndexResponse | null>(null) const [data, setData] = useState<ClassPrepIndexResponse | null>(null)
@@ -24,19 +20,16 @@ export function ClassPrepListPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [savingId, setSavingId] = useState<string | null>(null) const [savingId, setSavingId] = useState<string | null>(null)
const [saveMsg, setSaveMsg] = useState<string | null>(null) const [saveMsg, setSaveMsg] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
const yearOptions = useMemo(() => { preferredYear: schoolYear,
const out: string[] = [] })
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
return out
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setLoading(true) setLoading(true)
setError(null) setError(null)
fetchClassPrepIndex({ fetchClassPrepIndex({
school_year: schoolYear, school_year: schoolYear || selectedYear,
semester: semester || undefined, semester: semester || undefined,
}) })
.then((d) => { .then((d) => {
@@ -115,11 +108,11 @@ export function ClassPrepListPage() {
name="school_year" name="school_year"
id="school_year" id="school_year"
className="form-select form-select-sm w-auto" className="form-select form-select-sm w-auto"
defaultValue={schoolYear} defaultValue={schoolYear || selectedYear}
> >
{yearOptions.map((y) => ( {(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={y} value={y}> <option key={option.value} value={option.value}>
{y} {option.label}
</option> </option>
))} ))}
</select> </select>
@@ -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) ?? []
@@ -1,47 +1,255 @@
import { useParams } from 'react-router-dom' import { useEffect, useMemo, useState } from 'react'
import { ApiScopeNote, CompetitionPageShell } from './competitionWinnersShared' import { useNavigate, useParams } from 'react-router-dom'
import {
fetchAdminCompetitionWinnerCreate,
fetchAdminCompetitionWinnerSettings,
saveAdminCompetitionWinner,
} from '../../api/session'
import type { CompetitionWinnerAdminClassRow, CompetitionWinnerAdminFormResponse } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, COMPETITION_WINNERS_BASE } from './competitionWinnersShared'
/** CI `admin/competition_winners/form.php` — layout parity; persist requires admin competition APIs. */ type CompetitionFormState = {
title: string
class_section_id: string
semester: string
school_year: string
start_date: string
end_date: string
}
function numberOrNull(value: string): number | null {
const trimmed = value.trim()
if (trimmed === '') return null
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : null
}
function initialFormState(payload: CompetitionWinnerAdminFormResponse): CompetitionFormState {
const competition = payload.competition
return {
title: String(competition?.title ?? ''),
class_section_id:
competition?.class_section_id === null || competition?.class_section_id === undefined
? ''
: String(competition.class_section_id),
semester: String(competition?.semester ?? payload.defaultSemester ?? ''),
school_year: String(competition?.school_year ?? payload.defaultSchoolYear ?? ''),
start_date: String(competition?.start_date ?? ''),
end_date: String(competition?.end_date ?? ''),
}
}
function normalizeRowValue(value: number | string | null | undefined): string {
return value === null || value === undefined ? '' : String(value)
}
/** CI `admin/competition_winners/form.php` */
export function CompetitionWinnerFormPage() { export function CompetitionWinnerFormPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const competitionId = Number(id)
const [form, setForm] = useState<CompetitionFormState>({
title: '',
class_section_id: '',
semester: '',
school_year: '',
start_date: '',
end_date: '',
})
const [classRows, setClassRows] = useState<CompetitionWinnerAdminClassRow[]>([])
const [classSections, setClassSections] = useState<CompetitionWinnerAdminFormResponse['classSections']>([])
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
;(async () => {
try {
const response =
isEdit && Number.isFinite(competitionId) && competitionId > 0
? await fetchAdminCompetitionWinnerSettings(competitionId)
: await fetchAdminCompetitionWinnerCreate()
if (!response.ok) {
setError(response.message ?? 'Unable to load competition settings.')
return
}
setForm(initialFormState(response))
setClassRows(response.classRows ?? [])
setClassSections(response.classSections ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition settings.')
}
})()
}, [competitionId, isEdit])
const visibleRows = useMemo(() => {
const selectedClassId = numberOrNull(form.class_section_id)
if (selectedClassId === null) return classRows
return classRows.filter((row) => row.class_section_id === selectedClassId)
}, [classRows, form.class_section_id])
function updateClassRow(
classSectionId: number,
key:
| 'question_count'
| 'override_winners'
| 'prize_1'
| 'prize_2'
| 'prize_3'
| 'prize_4'
| 'prize_5'
| 'prize_6',
value: string,
) {
setClassRows((current) =>
current.map((row) => {
if (row.class_section_id !== classSectionId) return row
return {
...row,
[key]: value.trim() === '' ? null : Number(value),
}
}),
)
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
try {
setSaving(true)
setMessage(null)
setError(null)
const winnerOverrides = Object.fromEntries(
classRows.map((row) => [String(row.class_section_id), normalizeRowValue(row.override_winners)]),
)
const questionCounts = Object.fromEntries(
classRows.map((row) => [String(row.class_section_id), normalizeRowValue(row.question_count)]),
)
const prizes = Object.fromEntries(
classRows.map((row) => [
String(row.class_section_id),
{
1: normalizeRowValue(row.prize_1),
2: normalizeRowValue(row.prize_2),
3: normalizeRowValue(row.prize_3),
4: normalizeRowValue(row.prize_4),
5: normalizeRowValue(row.prize_5),
6: normalizeRowValue(row.prize_6),
},
]),
)
const result = await saveAdminCompetitionWinner(
{
title: form.title,
class_section_id: numberOrNull(form.class_section_id),
semester: form.semester || null,
school_year: form.school_year || null,
start_date: form.start_date || null,
end_date: form.end_date || null,
winner_overrides: winnerOverrides,
question_counts: questionCounts,
prizes,
},
isEdit && Number.isFinite(competitionId) && competitionId > 0 ? competitionId : undefined,
)
if (!result.ok) {
setError(result.message ?? 'Unable to save competition.')
return
}
const nextId = result.id ?? competitionId
setMessage(result.message ?? 'Competition saved.')
if (Number.isFinite(nextId) && nextId > 0) {
navigate(`${COMPETITION_WINNERS_BASE}/${nextId}/settings`, { replace: true })
return
}
navigate(COMPETITION_WINNERS_BASE)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to save competition.')
} finally {
setSaving(false)
}
}
return ( return (
<CompetitionPageShell title={isEdit ? 'Edit Competition' : 'Create Competition'}> <CompetitionPageShell title={isEdit ? 'Edit Competition' : 'Create Competition'}>
<ApiScopeNote> <ApiScopeNote>
Saving competitions and per-class prize grids requires POST endpoints that mirror CI ( This screen now saves directly through the Laravel admin competition endpoints, including per-class winner overrides
<code>store</code>, <code>update</code>). Connect the API to enable the fields below. and prize grids.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="card shadow-sm"> <form className="card shadow-sm" onSubmit={(e) => void onSubmit(e)}>
<div className="card-body row g-3"> <div className="card-body row g-3">
<div className="col-md-8"> <div className="col-md-8">
<label className="form-label">Title *</label> <label className="form-label">Title *</label>
<input className="form-control" disabled placeholder="Competition title" /> <input
className="form-control"
value={form.title}
onChange={(e) => setForm((current) => ({ ...current, title: e.target.value }))}
required
/>
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<label className="form-label">Class Section (optional)</label> <label className="form-label">Class Section (optional)</label>
<select className="form-select" disabled> <select
className="form-select"
value={form.class_section_id}
onChange={(e) => setForm((current) => ({ ...current, class_section_id: e.target.value }))}
>
<option value="">All classes</option> <option value="">All classes</option>
{(classSections ?? []).map((section) => (
<option key={String(section?.class_section_id ?? '')} value={String(section?.class_section_id ?? '')}>
{section?.class_section_name ?? section?.class_section_id}
</option>
))}
</select> </select>
<div className="form-text">Leave empty to manage winners for all classes.</div> <div className="form-text">Leave empty to manage winners for all classes.</div>
</div> </div>
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">Semester</label> <label className="form-label">Semester</label>
<input className="form-control" disabled /> <input
className="form-control"
value={form.semester}
onChange={(e) => setForm((current) => ({ ...current, semester: e.target.value }))}
/>
</div> </div>
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<input className="form-control" disabled placeholder="2025-2026" /> <input
className="form-control"
value={form.school_year}
onChange={(e) => setForm((current) => ({ ...current, school_year: e.target.value }))}
placeholder="2025-2026"
/>
<div className="form-text">Counts use the school year above.</div> <div className="form-text">Counts use the school year above.</div>
</div> </div>
<div className="col-md-4"> <div className="col-md-4">
<label className="form-label">Start Date</label> <label className="form-label">Start Date</label>
<input className="form-control" type="date" disabled /> <input
className="form-control"
type="date"
value={form.start_date}
onChange={(e) => setForm((current) => ({ ...current, start_date: e.target.value }))}
/>
</div> </div>
<div className="col-md-4"> <div className="col-md-4">
<label className="form-label">End Date</label> <label className="form-label">End Date</label>
<input className="form-control" type="date" disabled /> <input
className="form-control"
type="date"
value={form.end_date}
onChange={(e) => setForm((current) => ({ ...current, end_date: e.target.value }))}
/>
</div> </div>
<div className="col-12"> <div className="col-12">
@@ -65,23 +273,91 @@ export function CompetitionWinnerFormPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> {visibleRows.length === 0 ? (
<td colSpan={12} className="text-muted small"> <tr>
Class rows load from the backend when competition settings APIs are available. <td colSpan={12} className="text-muted small">
</td> No class rows found for the selected scope.
</tr> </td>
</tr>
) : (
visibleRows.map((row) => {
const finalWinners =
row.override_winners === null || row.override_winners === undefined
? row.auto_winners
: row.override_winners
return (
<tr key={row.class_section_id}>
<td>{row.class_section_name}</td>
<td>{row.student_count}</td>
<td>{row.auto_winners}</td>
<td>
<input
className="form-control form-control-sm"
type="number"
min="0"
value={normalizeRowValue(row.question_count)}
onChange={(e) => updateClassRow(row.class_section_id, 'question_count', e.target.value)}
/>
</td>
{[1, 2, 3, 4, 5, 6].map((rank) => (
<td key={rank}>
<input
className="form-control form-control-sm"
type="number"
min="0"
step="0.01"
value={normalizeRowValue(row[`prize_${rank}` as keyof CompetitionWinnerAdminClassRow] as number | null | undefined)}
onChange={(e) =>
updateClassRow(
row.class_section_id,
`prize_${rank}` as
| 'prize_1'
| 'prize_2'
| 'prize_3'
| 'prize_4'
| 'prize_5'
| 'prize_6',
e.target.value,
)
}
/>
</td>
))}
<td>
<input
className="form-control form-control-sm"
type="number"
min="0"
value={normalizeRowValue(row.override_winners)}
onChange={(e) => updateClassRow(row.class_section_id, 'override_winners', e.target.value)}
/>
</td>
<td>{finalWinners}</td>
</tr>
)
})
)}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div className="col-12"> <div className="col-12 d-flex gap-2">
<button type="button" className="btn btn-primary" disabled> <button type="submit" className="btn btn-primary" disabled={saving}>
Save {saving ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => navigate(COMPETITION_WINNERS_BASE)}
disabled={saving}
>
Back
</button> </button>
</div> </div>
</div> </div>
</div> </form>
</CompetitionPageShell> </CompetitionPageShell>
) )
} }
@@ -1,85 +1,101 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { fetchPublishedCompetition } from '../../api/session' import { fetchAdminCompetitionWinnerPreview, publishAdminCompetitionWinner } from '../../api/session'
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types' import type { CompetitionWinnerAdminPreviewResponse, CompetitionWinnerPreviewStudentRow } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared' import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared'
function usePublishedCompetition(id: number) {
const [competition, setCompetition] = useState<PublicCompetitionRow | null>(null)
const [winnersByClass, setWinnersByClass] = useState<Record<string, PublicCompetitionWinnerRow[]>>({})
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
;(async () => {
try {
const response = await fetchPublishedCompetition(id)
if (!response.status || !response.data) {
setError(response.message ?? 'Competition not found.')
return
}
setCompetition(response.data.competition)
setWinnersByClass(response.data.winners_by_class ?? {})
setSectionMap(response.data.section_map ?? {})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners.')
}
})()
}, [id])
return { competition, winnersByClass, sectionMap, error }
}
/** CI `admin/competition_winners/preview.php` */ /** CI `admin/competition_winners/preview.php` */
export function CompetitionWinnerPreviewPage() { export function CompetitionWinnerPreviewPage() {
const { id } = useParams() const { id } = useParams()
const competitionId = Number(id) const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, error } = usePublishedCompetition(competitionId) const [data, setData] = useState<CompetitionWinnerAdminPreviewResponse | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [publishing, setPublishing] = useState(false)
async function load() {
try {
const response = await fetchAdminCompetitionWinnerPreview(competitionId)
setData(response)
setError(response.ok ? null : response.message ?? 'Competition not found.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners preview.')
}
}
useEffect(() => {
if (!Number.isFinite(competitionId) || competitionId <= 0) return
void load()
}, [competitionId])
async function handlePublish() {
try {
setPublishing(true)
setMessage(null)
const result = await publishAdminCompetitionWinner(competitionId)
if (!result.ok) {
setError(result.message ?? 'Unable to publish competition winners.')
return
}
setMessage(result.message ?? 'Competition winners published.')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to publish competition winners.')
} finally {
setPublishing(false)
}
}
return ( return (
<CompetitionPageShell title="Preview Winners"> <CompetitionPageShell title="Preview Winners">
<ApiScopeNote> <ApiScopeNote>
Preview reflects published winners from the public API. Unpublished drafts are not shown until the backend exposes an Preview is generated from the admin scoring data, so it works before a competition is publicly published.
admin preview.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? ( {message ? <div className="alert alert-success">{message}</div> : null}
{data?.competition ? (
<> <>
<div className="mb-3"> <div className="mb-3 d-flex flex-wrap justify-content-between align-items-start gap-2">
<div> <div>
<strong>Competition:</strong> {competitionLabel(competition)} <div>
</div> <strong>Competition:</strong> {competitionLabel(data.competition)}
<div> </div>
<strong>School Year:</strong> {competition.school_year ?? '—'} <div>
<strong>School Year:</strong> {data.competition.school_year ?? '—'}
</div>
<div>
<strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
</div>
</div> </div>
<button type="button" className="btn btn-primary" disabled={publishing} onClick={() => void handlePublish()}>
{publishing ? 'Publishing...' : 'Publish Winners'}
</button>
</div> </div>
{Object.keys(winnersByClass).length === 0 ? ( {Object.keys(data.topByClass ?? {}).length === 0 ? (
<div className="alert alert-warning">No published winners found for this competition.</div> <div className="alert alert-warning">No ranked winners found for this competition yet.</div>
) : ( ) : (
Object.entries(winnersByClass).map(([classId, rows]) => ( Object.entries(data.topByClass ?? {}).map(([classId, rows]) => (
<div key={classId} className="mb-4"> <div key={classId} className="mb-4">
<h5 className="mt-4">Top Winners - {sectionMap[classId] ?? `Class ${classId}`}</h5> <h5 className="mt-4">
Top Winners - {data.classMap?.[classId] ?? `Class ${classId}`}
</h5>
<table className="table table-bordered table-sm"> <table className="table table-bordered table-sm">
<thead className="table-light"> <thead className="table-light">
<tr> <tr>
<th>Rank</th> <th>Rank</th>
<th>Student</th> <th>Student</th>
<th>Score</th> <th>Score</th>
<th>Question Count</th>
<th>Prize</th> <th>Prize</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, index) => ( {(rows as CompetitionWinnerPreviewStudentRow[]).map((row, index) => (
<tr key={`${classId}-${index}`}> <tr key={`${classId}-${index}`}>
<td>{row.rank ?? index + 1}</td> <td>{row.rank ?? index + 1}</td>
<td> <td>{row.name ?? `Student #${row.student_id ?? ''}`}</td>
{row.name ??
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
`Student #${row.student_id ?? ''}`)}
</td>
<td>{row.score ?? '—'}</td> <td>{row.score ?? '—'}</td>
<td>{data.questionCounts?.[classId] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td> <td>{row.prize_amount ?? '—'}</td>
</tr> </tr>
))} ))}
@@ -1,10 +1,10 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { import {
fetchCompetitionScoresDetail, fetchAdminCompetitionWinnerScores,
saveCompetitionScores, saveAdminCompetitionWinnerScores,
} from '../../api/session' } from '../../api/session'
import type { CompetitionScoresEditResponse, CompetitionScoreStudentRow } from '../../api/types' import type { CompetitionScoreStudentRow, CompetitionWinnerAdminScoresResponse } from '../../api/types'
import { import {
ApiScopeNote, ApiScopeNote,
CompetitionPageShell, CompetitionPageShell,
@@ -12,20 +12,26 @@ import {
competitionLabel, competitionLabel,
} from './competitionWinnersShared' } from './competitionWinnersShared'
/** CI `admin/competition_winners/scores.php` — enter scores (teacher competition API). */ function numberOrNull(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
/** CI `admin/competition_winners/scores.php` */
export function CompetitionWinnerScoresPage() { export function CompetitionWinnerScoresPage() {
const { id } = useParams() const { id } = useParams()
const competitionId = Number(id) const competitionId = Number(id)
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const selectedClassId = Number(searchParams.get('class_section_id') ?? 0) || null const selectedClassId = numberOrNull(searchParams.get('class_section_id'))
const [data, setData] = useState<CompetitionScoresEditResponse | null>(null) const [data, setData] = useState<CompetitionWinnerAdminScoresResponse | null>(null)
const [scores, setScores] = useState<Record<string, number | string>>({}) const [scores, setScores] = useState<Record<string, number | string>>({})
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
async function load(classSectionId = selectedClassId) { async function load(classSectionId = selectedClassId) {
try { try {
const response = await fetchCompetitionScoresDetail(competitionId, classSectionId) const response = await fetchAdminCompetitionWinnerScores(competitionId, classSectionId)
setData(response) setData(response)
setScores(response.scoreMap ?? {}) setScores(response.scoreMap ?? {})
setError(response.ok ? null : response.message ?? 'Unable to load competition scores.') setError(response.ok ? null : response.message ?? 'Unable to load competition scores.')
@@ -39,12 +45,23 @@ export function CompetitionWinnerScoresPage() {
void load() void load()
}, [competitionId, selectedClassId]) }, [competitionId, selectedClassId])
const classOptions = useMemo(
() =>
(data?.classSections ?? [])
.map((section) => ({
id: numberOrNull(section.class_section_id),
label: String(section.class_section_name ?? section.class_section_id ?? ''),
}))
.filter((section): section is { id: number; label: string } => section.id !== null),
[data?.classSections],
)
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
if (!competitionId) return if (!competitionId) return
try { try {
const result = await saveCompetitionScores(competitionId, { const result = await saveAdminCompetitionWinnerScores(competitionId, {
class_section_id: data?.classSectionId ?? selectedClassId, class_section_id: selectedClassId ?? data?.activeClassSectionId ?? null,
scores, scores,
}) })
setMessage(result.message ?? 'Scores saved.') setMessage(result.message ?? 'Scores saved.')
@@ -59,7 +76,8 @@ export function CompetitionWinnerScoresPage() {
return ( return (
<CompetitionPageShell title="Enter Scores"> <CompetitionPageShell title="Enter Scores">
<ApiScopeNote> <ApiScopeNote>
Uses the teacher-facing competition-scores API; your account must be assigned to the class section being scored. This admin score-entry page uses the dedicated Laravel competition-winners endpoints, so it works for administrator
workflows without relying on teacher class assignments.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null} {message ? <div className="alert alert-success">{message}</div> : null}
@@ -74,18 +92,27 @@ export function CompetitionWinnerScoresPage() {
</div> </div>
<div> <div>
<strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong>{' '} <strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong>{' '}
{data.questionCount ?? '—'} {data.classQuestionCount ?? '—'} | <strong>Final Winners:</strong> {data.classFinalWinners ?? '—'}
</div> </div>
</div> </div>
<form onSubmit={(e) => void onSubmit(e)}> <form onSubmit={(e) => void onSubmit(e)}>
{data.classSelectionLocked === false ? ( {data.classSelectionLocked === false ? (
<div className="mb-3" style={{ maxWidth: 360 }}> <div className="mb-3" style={{ maxWidth: 360 }}>
<label className="form-label">Class Section ID</label> <label className="form-label">Class Section</label>
<input <select
className="form-control" className="form-select"
value={String(selectedClassId ?? data.classSectionId ?? '')} value={String(selectedClassId ?? data.activeClassSectionId ?? '')}
onChange={(e) => setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})} onChange={(e) =>
/> setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})
}
>
<option value="">Select a class</option>
{classOptions.map((section) => (
<option key={section.id} value={section.id}>
{section.label}
</option>
))}
</select>
</div> </div>
) : null} ) : null}
<div className="table-responsive"> <div className="table-responsive">
@@ -94,7 +121,7 @@ export function CompetitionWinnerScoresPage() {
<tr> <tr>
<th>School ID</th> <th>School ID</th>
<th>Student</th> <th>Student</th>
<th>Score{data.questionCount ? ` (max ${data.questionCount})` : ''}</th> <th>Score{data.classQuestionCount ? ` (max ${data.classQuestionCount})` : ''}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -120,8 +147,8 @@ export function CompetitionWinnerScoresPage() {
type="number" type="number"
step="1" step="1"
min="0" min="0"
max={data.questionCount ?? undefined} max={data.classQuestionCount ?? undefined}
disabled={Boolean(data.isLocked)} disabled={Boolean(data.competition?.is_locked)}
value={String(scores[String(studentId)] ?? '')} value={String(scores[String(studentId)] ?? '')}
onChange={(e) => onChange={(e) =>
setScores((current) => ({ setScores((current) => ({
@@ -139,7 +166,7 @@ export function CompetitionWinnerScoresPage() {
</table> </table>
</div> </div>
<div className="d-flex gap-2 mt-3"> <div className="d-flex gap-2 mt-3">
<button className="btn btn-primary" disabled={Boolean(data.isLocked)}> <button className="btn btn-primary" disabled={Boolean(data.competition?.is_locked)}>
Save Scores Save Scores
</button> </button>
<Link className="btn btn-outline-secondary" to={`${COMPETITION_WINNERS_BASE}/${competitionId}/preview`}> <Link className="btn btn-outline-secondary" to={`${COMPETITION_WINNERS_BASE}/${competitionId}/preview`}>
@@ -1,77 +1,54 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { fetchPublishedCompetition } from '../../api/session' import { fetchAdminCompetitionWinnerWinners } from '../../api/session'
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types' import type { CompetitionWinnerAdminWinnerRow, CompetitionWinnerAdminWinnersResponse } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared' import { ApiScopeNote, CompetitionPageShell, competitionLabel } from './competitionWinnersShared'
function usePublishedCompetitionFull(id: number) {
const [competition, setCompetition] = useState<PublicCompetitionRow | null>(null)
const [winnersByClass, setWinnersByClass] = useState<Record<string, PublicCompetitionWinnerRow[]>>({})
const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
const [questionCounts, setQuestionCounts] = useState<Record<string, number>>({})
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(id) || id <= 0) return
;(async () => {
try {
const response = await fetchPublishedCompetition(id)
if (!response.status || !response.data) {
setError(response.message ?? 'Competition not found.')
return
}
setCompetition(response.data.competition)
setWinnersByClass(response.data.winners_by_class ?? {})
setSectionMap(response.data.section_map ?? {})
setQuestionCounts(response.data.question_counts ?? {})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners.')
}
})()
}, [id])
return { competition, winnersByClass, sectionMap, questionCounts, error }
}
/** CI `admin/competition_winners/winners.php` */ /** CI `admin/competition_winners/winners.php` */
export function CompetitionWinnerWinnersPage() { export function CompetitionWinnerWinnersPage() {
const { id } = useParams() const { id } = useParams()
const competitionId = Number(id) const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, questionCounts, error } = const [data, setData] = useState<CompetitionWinnerAdminWinnersResponse | null>(null)
usePublishedCompetitionFull(competitionId) const [error, setError] = useState<string | null>(null)
const flatRows = useMemo( useEffect(() => {
() => if (!Number.isFinite(competitionId) || competitionId <= 0) return
Object.entries(winnersByClass).flatMap(([classId, rows]) => ;(async () => {
rows.map((row) => ({ ...row, _classId: classId })), try {
), const response = await fetchAdminCompetitionWinnerWinners(competitionId)
[winnersByClass], setData(response)
setError(response.ok ? null : response.message ?? 'Competition not found.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load winners.')
}
})()
}, [competitionId])
const totalPrize = useMemo(
() => (data?.rows ?? []).reduce((sum, row) => sum + Number(row.prize_amount ?? 0), 0),
[data?.rows],
) )
const totalPrize = flatRows.reduce((sum, row) => sum + Number(row.prize_amount ?? 0), 0)
return ( return (
<CompetitionPageShell title="School Winners"> <CompetitionPageShell title="School Winners">
<ApiScopeNote> <ApiScopeNote>
Prize totals and rankings come from the published winners API. Export to recognition PDF requires an admin endpoint if This listing comes from the admin winners table and reflects the last published winner set for the competition.
not yet in Laravel.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? ( {data?.competition ? (
<> <>
<div className="mb-3"> <div className="mb-3">
<div> <div>
<strong>Competition:</strong> {competitionLabel(competition)} <strong>Competition:</strong> {competitionLabel(data.competition)}
</div> </div>
<div> <div>
<strong>School Year:</strong> {competition.school_year ?? '—'} <strong>School Year:</strong> {data.competition.school_year ?? '—'}
</div> </div>
<div> <div>
<strong>Published:</strong> {competition.is_published ? 'Yes' : 'No'} <strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
</div> </div>
</div> </div>
{flatRows.length === 0 ? ( {(data.rows ?? []).length === 0 ? (
<div className="alert alert-warning">No published winners yet.</div> <div className="alert alert-warning">No published winners yet.</div>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
@@ -80,24 +57,24 @@ export function CompetitionWinnerWinnersPage() {
<tr> <tr>
<th>Class</th> <th>Class</th>
<th>Student</th> <th>Student</th>
<th>School ID</th>
<th>Rank</th> <th>Rank</th>
<th>Score</th> <th>Score</th>
<th>Question Count</th>
<th>Prize</th> <th>Prize</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{flatRows.map((row, index) => ( {(data.rows ?? []).map((row: CompetitionWinnerAdminWinnerRow, index) => (
<tr key={`${row._classId}-${index}`}> <tr key={`${row.class_section_id ?? 'class'}-${row.student_id ?? index}-${index}`}>
<td>{sectionMap[String(row._classId)] ?? `Class ${row._classId}`}</td> <td>{data.sectionMap?.[String(row.class_section_id ?? '')] ?? `Class ${row.class_section_id ?? ''}`}</td>
<td> <td>
{row.name ?? {row.name ??
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || (`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
`Student #${row.student_id ?? ''}`)} `Student #${row.student_id ?? ''}`)}
</td> </td>
<td>{row.school_id ?? '—'}</td>
<td>{row.rank ?? '—'}</td> <td>{row.rank ?? '—'}</td>
<td>{row.score ?? '—'}</td> <td>{row.score ?? '—'}</td>
<td>{questionCounts[String(row._classId)] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td> <td>{row.prize_amount ?? '—'}</td>
</tr> </tr>
))} ))}
@@ -1,62 +1,109 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { fetchCompetitionScoresIndex, fetchPublishedCompetitions } from '../../api/session' import {
import type { CompetitionScoresIndexCompetitionRow, PublicCompetitionRow } from '../../api/types' exportAdminCompetitionWinnerQuiz,
fetchAdminCompetitionWinnersIndex,
lockAdminCompetitionWinner,
unlockAdminCompetitionWinner,
} from '../../api/session'
import type { CompetitionWinnerAdminCompetitionRow } from '../../api/types'
import { ApiScopeNote, CompetitionPageShell, COMPETITION_WINNERS_BASE } from './competitionWinnersShared' import { ApiScopeNote, CompetitionPageShell, COMPETITION_WINNERS_BASE } from './competitionWinnersShared'
/** CI `admin/competition_winners/index.php` */ function toFiniteNumber(value: unknown): number | null {
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? n : null
}
function competitionId(row: CompetitionWinnerAdminCompetitionRow): number | null {
return toFiniteNumber(row.id ?? row.competition_id)
}
export function CompetitionWinnersIndexPage() { export function CompetitionWinnersIndexPage() {
const [publishedRows, setPublishedRows] = useState<PublicCompetitionRow[]>([]) const [rows, setRows] = useState<CompetitionWinnerAdminCompetitionRow[]>([])
const [adminRows, setAdminRows] = useState<CompetitionScoresIndexCompetitionRow[]>([])
const [sectionMap, setSectionMap] = useState<Record<string, string>>({}) const [sectionMap, setSectionMap] = useState<Record<string, string>>({})
const [activeClassName, setActiveClassName] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
async function load() {
try {
const response = await fetchAdminCompetitionWinnersIndex()
if (!response.ok) {
setError(response.message ?? 'Unable to load competition winners data.')
return
}
setRows(response.competitions ?? [])
setSectionMap(response.sectionMap ?? {})
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition winners data.')
}
}
useEffect(() => { useEffect(() => {
;(async () => { void load()
try {
const [published, teacher] = await Promise.allSettled([
fetchPublishedCompetitions(),
fetchCompetitionScoresIndex(),
])
if (published.status === 'fulfilled') {
setPublishedRows(published.value.data?.competitions ?? [])
}
if (teacher.status === 'fulfilled') {
setAdminRows(teacher.value.competitions ?? [])
setSectionMap(teacher.value.sectionMap ?? {})
setActiveClassName(teacher.value.activeClassName ?? null)
}
if (published.status === 'rejected' && teacher.status === 'rejected') {
setError('Unable to load competition winners data.')
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load competition winners data.')
}
})()
}, []) }, [])
const tableRows = useMemo(() => { async function handleExport(row: CompetitionWinnerAdminCompetitionRow) {
if (adminRows.length > 0) return adminRows const id = competitionId(row)
return publishedRows.map((r) => ({ if (id === null) {
id: r.id, setError('This competition does not have a valid id yet.')
title: r.title, return
class_section_id: r.class_section_id, }
is_locked: r.is_locked,
is_published: r.is_published, try {
})) as CompetitionScoresIndexCompetitionRow[] setBusyAction(`export:${id}`)
}, [adminRows, publishedRows]) setMessage(null)
const result = await exportAdminCompetitionWinnerQuiz(id, {
class_section_id: toFiniteNumber(row.class_section_id),
})
if (!result.ok) {
setError(result.message ?? 'Unable to export competition scores to quiz.')
return
}
setMessage(result.message ?? 'Competition scores exported to quiz.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to export competition scores to quiz.')
} finally {
setBusyAction(null)
}
}
async function handleLockToggle(row: CompetitionWinnerAdminCompetitionRow) {
const id = competitionId(row)
if (id === null) {
setError('This competition does not have a valid id yet.')
return
}
try {
setBusyAction(`lock:${id}`)
setMessage(null)
const result = row.is_locked
? await unlockAdminCompetitionWinner(id)
: await lockAdminCompetitionWinner(id)
if (!result.ok) {
setError(result.message ?? 'Unable to update competition lock state.')
return
}
setMessage(result.message ?? (row.is_locked ? 'Competition unlocked.' : 'Competition locked.'))
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to update competition lock state.')
} finally {
setBusyAction(null)
}
}
return ( return (
<CompetitionPageShell title="Competition Winners"> <CompetitionPageShell title="Competition Winners">
<ApiScopeNote> <ApiScopeNote>
Lock/unlock, export quiz, and competition CRUD match CI once Laravel exposes those admin routes. Score entry and Admin competition management now uses dedicated Laravel endpoints. Draft competitions keep working here, and preview
published winners work with the current API. is no longer limited to published rows.
</ApiScopeNote> </ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"> <div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div /> <div />
@@ -79,63 +126,75 @@ export function CompetitionWinnersIndexPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tableRows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="text-muted"> <td colSpan={7} className="text-muted">
No competitions found. No competitions found.
</td> </td>
</tr> </tr>
) : ( ) : (
tableRows.map((c) => { rows.map((row) => {
const sectionId = Number(c.class_section_id ?? 0) const id = competitionId(row)
const sectionId = toFiniteNumber(row.class_section_id) ?? 0
const sectionName = const sectionName =
sectionId > 0 ? (sectionMap[String(sectionId)] ?? String(sectionId)) : 'All' sectionId > 0 ? (sectionMap[String(sectionId)] ?? String(sectionId)) : 'All'
const isLocked = Boolean(c.is_locked) const title = String(row.title ?? (id !== null ? `Competition #${id}` : 'Competition'))
const isPublished = Boolean(c.is_published) const exportBusy = busyAction === `export:${id}`
const id = Number(c.id) const lockBusy = busyAction === `lock:${id}`
return ( return (
<tr key={id}> <tr key={id !== null ? String(id) : `${title}-${sectionId}`}>
<td>{id}</td> <td>{id ?? '—'}</td>
<td>{String(c.title ?? `Competition #${id}`)}</td> <td>{title}</td>
<td>{sectionName}</td> <td>{sectionName}</td>
<td>Tiered per class</td> <td>Tiered per class</td>
<td>{isPublished ? 'Yes' : 'No'}</td> <td>{row.is_published ? 'Yes' : 'No'}</td>
<td>{isLocked ? 'Yes' : 'No'}</td> <td>{row.is_locked ? 'Yes' : 'No'}</td>
<td> <td>
<div className="d-flex flex-wrap gap-1"> <div className="d-flex flex-wrap gap-1">
<Link {id !== null ? (
className="btn btn-sm btn-outline-secondary" <>
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`} <Link
> className="btn btn-sm btn-outline-secondary"
Settings to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
</Link> >
<Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}> Settings
Enter Scores </Link>
</Link> <Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}>
<Link Enter Scores
className="btn btn-sm btn-outline-secondary" </Link>
to={`${COMPETITION_WINNERS_BASE}/${id}/preview`} <Link
> className="btn btn-sm btn-outline-secondary"
Preview to={`${COMPETITION_WINNERS_BASE}/${id}/preview`}
</Link> >
<Link Preview
className="btn btn-sm btn-outline-secondary" </Link>
to={`${COMPETITION_WINNERS_BASE}/${id}/winners`} <Link
> className="btn btn-sm btn-outline-secondary"
Winners to={`${COMPETITION_WINNERS_BASE}/${id}/winners`}
</Link> >
<button type="button" className="btn btn-sm btn-outline-success" disabled title="Requires API"> Winners
Export Scores to Quiz </Link>
</button> </>
{isLocked ? (
<button type="button" className="btn btn-sm btn-outline-warning" disabled title="Requires API">
Unlock
</button>
) : ( ) : (
<button type="button" className="btn btn-sm btn-outline-danger" disabled title="Requires API"> <span className="text-muted small me-2">Invalid competition id</span>
Lock
</button>
)} )}
<button
type="button"
className="btn btn-sm btn-outline-success"
disabled={id === null || exportBusy || lockBusy}
onClick={() => void handleExport(row)}
>
{exportBusy ? 'Exporting...' : 'Export Scores to Quiz'}
</button>
<button
type="button"
className={`btn btn-sm ${row.is_locked ? 'btn-outline-warning' : 'btn-outline-danger'}`}
disabled={id === null || exportBusy || lockBusy}
onClick={() => void handleLockToggle(row)}
>
{lockBusy ? 'Saving...' : row.is_locked ? 'Unlock' : 'Lock'}
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -145,10 +204,6 @@ export function CompetitionWinnersIndexPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
{activeClassName ? (
<p className="text-muted small mt-3 mb-0">Active class context: {activeClassName}</p>
) : null}
</CompetitionPageShell> </CompetitionPageShell>
) )
} }
+29 -25
View File
@@ -1,10 +1,6 @@
import { type FormEvent } from 'react' import { type FormEvent, useEffect, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
function defaultSchoolYear() {
const y = new Date().getFullYear()
return `${y}-${y + 1}`
}
export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) { export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -12,24 +8,26 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
const { pathname } = useLocation() const { pathname } = useLocation()
const schoolYear = searchParams.get('school_year') ?? '' const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? '' const semester = searchParams.get('semester') ?? ''
const { options, selectedYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
const [yearValue, setYearValue] = useState(schoolYear || selectedYear)
const [semesterValue, setSemesterValue] = useState(semester)
const years = useEffect(() => {
schoolYears && schoolYears.length > 0 setYearValue(schoolYear || selectedYear)
? schoolYears }, [schoolYear, selectedYear])
: (() => {
const out: string[] = [] useEffect(() => {
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`) setSemesterValue(semester)
return out }, [semester])
})()
function onSubmit(e: FormEvent<HTMLFormElement>) { function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
const fd = new FormData(e.currentTarget)
const next = new URLSearchParams() const next = new URLSearchParams()
const sy = String(fd.get('school_year') ?? '') if (yearValue) next.set('school_year', yearValue)
const sem = String(fd.get('semester') ?? '') if (semesterValue) next.set('semester', semesterValue)
if (sy) next.set('school_year', sy)
if (sem) next.set('semester', sem)
navigate({ pathname, search: next.toString() ? `?${next}` : '' }) navigate({ pathname, search: next.toString() ? `?${next}` : '' })
} }
@@ -46,11 +44,12 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
name="school_year" name="school_year"
className="form-select form-select-sm" className="form-select form-select-sm"
style={{ minWidth: 180 }} style={{ minWidth: 180 }}
defaultValue={schoolYear || years[0] || defaultSchoolYear()} value={yearValue}
onChange={(event) => setYearValue(event.target.value)}
> >
{years.map((y) => ( {options.map((option) => (
<option key={y} value={y}> <option key={option.value} value={option.value}>
{y} {option.label}
</option> </option>
))} ))}
</select> </select>
@@ -63,7 +62,8 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
name="semester" name="semester"
className="form-select form-select-sm" className="form-select form-select-sm"
style={{ minWidth: 140 }} style={{ minWidth: 140 }}
defaultValue={semester} value={semesterValue}
onChange={(event) => setSemesterValue(event.target.value)}
> >
<option value=""></option> <option value=""></option>
<option value="Fall">Fall</option> <option value="Fall">Fall</option>
@@ -77,7 +77,11 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
<button <button
type="button" type="button"
className="btn btn-outline-secondary btn-sm ms-1" className="btn btn-outline-secondary btn-sm ms-1"
onClick={() => navigate({ pathname, search: '' })} onClick={() => {
setYearValue(selectedYear)
setSemesterValue('')
navigate({ pathname, search: '' })
}}
> >
Reset Reset
</button> </button>
@@ -172,7 +172,7 @@ export function DiscountApplyVoucherPage() {
aria-label="Select all" aria-label="Select all"
/> />
</th> </th>
<th>Parent ID</th> <th>School ID</th>
<th>Parent Name</th> <th>Parent Name</th>
<th>Email</th> <th>Email</th>
<th>Has Discount?</th> <th>Has Discount?</th>
+1 -1
View File
@@ -143,7 +143,7 @@ export function ReverseDiscountPage() {
<thead> <thead>
<tr> <tr>
<th>Select</th> <th>Select</th>
<th>Parent ID</th> <th>School ID</th>
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
<th>Has discount?</th> <th>Has discount?</th>
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
fetchEnrollmentWithdrawalPage, fetchEnrollmentWithdrawalPage,
postEnrollmentWithdrawalAssignClass, postEnrollmentWithdrawalAssignClass,
@@ -88,6 +89,10 @@ export function EnrollmentWithdrawalPage() {
/** Pending edits: undefined means “use row value”. */ /** Pending edits: undefined means “use row value”. */
const [pendingStatus, setPendingStatus] = useState<Record<number, string>>({}) const [pendingStatus, setPendingStatus] = useState<Record<number, string>>({})
const [pendingClass, setPendingClass] = useState<Record<number, string>>({}) const [pendingClass, setPendingClass] = useState<Record<number, string>>({})
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: selectedYear || schoolYearParam,
})
const readOnly = missingYear || !isCurrentYear const readOnly = missingYear || !isCurrentYear
@@ -282,8 +287,7 @@ export function EnrollmentWithdrawalPage() {
setBulkWorking(false) setBulkWorking(false)
} }
const yearSelectValue = const yearSelectValue = schoolYearParam || selectedYear || defaultYear
schoolYearParam || selectedYear || (schoolYears.length > 0 ? schoolYears[0] : '')
const semesterSelectValue = semesterParam const semesterSelectValue = semesterParam
return ( return (
@@ -326,10 +330,10 @@ export function EnrollmentWithdrawalPage() {
defaultValue={yearSelectValue} defaultValue={yearSelectValue}
key={`${yearSelectValue}-${semesterSelectValue}`} key={`${yearSelectValue}-${semesterSelectValue}`}
> >
{schoolYears.length > 0 ? ( {options.length > 0 ? (
schoolYears.map((y) => ( options.map((option) => (
<option key={y} value={y}> <option key={option.value} value={option.value}>
{y} {option.label}
</option> </option>
)) ))
) : ( ) : (
@@ -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 }}
/> />
) : ( ) : (
+1 -1
View File
@@ -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
} }
+21 -6
View File
@@ -2,6 +2,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { fetchParticipation, postParticipation } from '../../api/grading' import { fetchParticipation, postParticipation } from '../../api/grading'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { GRADING_PATH } from './gradingPaths' import { GRADING_PATH } from './gradingPaths'
/** CI `grading/participation.php` */ /** CI `grading/participation.php` */
@@ -11,6 +12,10 @@ function normalizeParticipationScore(value: unknown): number | string | null {
export function ParticipationPage() { export function ParticipationPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const requestedSchoolYear = searchParams.get('school_year') ?? ''
const { selectedYear } = useSchoolYearOptions({
preferredYear: requestedSchoolYear,
})
const [rows, setRows] = useState< const [rows, setRows] = useState<
Array<{ Array<{
student_id: number student_id: number
@@ -24,18 +29,24 @@ export function ParticipationPage() {
const [title] = useState('Participation Scores') const [title] = useState('Participation Scores')
const [sectionName, setSectionName] = useState('') const [sectionName, setSectionName] = useState('')
const [semester, setSemester] = useState(searchParams.get('semester') ?? '') const [semester, setSemester] = useState(searchParams.get('semester') ?? '')
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '') const [schoolYear, setSchoolYear] = useState(requestedSchoolYear || selectedYear)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [tableSearch, setTableSearch] = useState('') const [tableSearch, setTableSearch] = useState('')
const classSectionId = searchParams.get('class_section_id') ?? '' const classSectionId = searchParams.get('class_section_id') ?? ''
const effectiveSchoolYear = schoolYear || selectedYear
const participationSearchParams = useMemo(() => {
const next = new URLSearchParams(searchParams)
if (effectiveSchoolYear) next.set('school_year', effectiveSchoolYear)
return next
}, [effectiveSchoolYear, searchParams])
useEffect(() => { useEffect(() => {
let c = false let c = false
setLoading(true) setLoading(true)
fetchParticipation(searchParams) fetchParticipation(participationSearchParams)
.then((p) => { .then((p) => {
if (c) return if (c) return
if (p && typeof p === 'object') { if (p && typeof p === 'object') {
@@ -57,7 +68,11 @@ export function ParticipationPage() {
setLocked(Boolean(o.scores_locked ?? o.scoresLocked ?? o.locked)) setLocked(Boolean(o.scores_locked ?? o.scoresLocked ?? o.locked))
setSectionName(typeof o.class_section_name === 'string' ? o.class_section_name : '') setSectionName(typeof o.class_section_name === 'string' ? o.class_section_name : '')
setSemester(typeof o.semester === 'string' ? o.semester : (searchParams.get('semester') ?? '')) setSemester(typeof o.semester === 'string' ? o.semester : (searchParams.get('semester') ?? ''))
setSchoolYear(typeof o.school_year === 'string' ? o.school_year : (searchParams.get('school_year') ?? '')) setSchoolYear(
typeof o.school_year === 'string' && o.school_year.trim() !== ''
? o.school_year
: effectiveSchoolYear,
)
} }
setError(null) setError(null)
}) })
@@ -70,7 +85,7 @@ export function ParticipationPage() {
return () => { return () => {
c = true c = true
} }
}, [searchParams]) }, [effectiveSchoolYear, participationSearchParams, searchParams])
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
const normalized = tableSearch.toLowerCase().trim() const normalized = tableSearch.toLowerCase().trim()
@@ -95,7 +110,7 @@ export function ParticipationPage() {
try { try {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
class_section_id: classSectionId ? Number(classSectionId) : undefined, class_section_id: classSectionId ? Number(classSectionId) : undefined,
school_year: schoolYear || undefined, school_year: effectiveSchoolYear || undefined,
semester: semester || undefined, semester: semester || undefined,
scores: rows.reduce<Record<string, { score: number | string | null }>>((carry, row) => { scores: rows.reduce<Record<string, { score: number | string | null }>>((carry, row) => {
carry[String(row.student_id)] = { score: row.score ?? '' } carry[String(row.student_id)] = { score: row.score ?? '' }
@@ -103,7 +118,7 @@ export function ParticipationPage() {
}, {}), }, {}),
} }
await postParticipation(body) await postParticipation(body)
const next = await fetchParticipation(searchParams) const next = await fetchParticipation(participationSearchParams)
if (next && typeof next === 'object') { if (next && typeof next === 'object') {
const o = next as Record<string, unknown> const o = next as Record<string, unknown>
const students = Array.isArray(o.students) ? o.students : [] const students = Array.isArray(o.students) ? o.students : []
@@ -0,0 +1,183 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchInventoryDashboard } from '../../api/inventory'
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
/**
* Inventory Dashboard — summary counts by type, low stock, out of stock, etc.
* Backend: GET /api/v1/inventory/dashboard
*/
export function InventoryDashboardPage() {
const [searchParams] = useSearchParams()
const [data, setData] = useState<Record<string, unknown>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchInventoryDashboard(searchParams)
.then((res) => {
if (res && typeof res === 'object') {
setData(res as Record<string, unknown>)
}
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load dashboard.')
})
.finally(() => setLoading(false))
}, [searchParams])
useEffect(() => {
load()
}, [load])
const byType = (data.by_type as Record<string, number>) ?? {}
const totalItems = Number(data.total_items ?? 0)
const lowStockCount = Number(data.low_stock_count ?? 0)
const outOfStockCount = Number(data.out_of_stock_count ?? 0)
const needsRepairCount = Number(data.needs_repair_count ?? 0)
const missingCount = Number(data.missing_count ?? 0)
return (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Inventory Dashboard</h3>
<div className="d-flex gap-2">
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/stock-status">
Stock Status
</Link>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
{loading ? (
<p className="text-muted px-3">Loading</p>
) : (
<div className="row g-3 px-3">
{/* Summary Cards */}
<div className="col-sm-6 col-lg-4">
<div className="card border-primary h-100">
<div className="card-body text-center">
<h5 className="card-title text-primary">{totalItems}</h5>
<p className="card-text">Total Items</p>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-4">
<div className="card border-warning h-100">
<div className="card-body text-center">
<h5 className="card-title text-warning">{lowStockCount}</h5>
<p className="card-text">Low Stock Items</p>
{lowStockCount > 0 ? (
<Link className="btn btn-sm btn-warning" to="/app/administrator/inventory/low-stock">
View Low Stock
</Link>
) : null}
</div>
</div>
</div>
<div className="col-sm-6 col-lg-4">
<div className="card border-danger h-100">
<div className="card-body text-center">
<h5 className="card-title text-danger">{outOfStockCount}</h5>
<p className="card-text">Out of Stock</p>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-4">
<div className="card border-info h-100">
<div className="card-body text-center">
<h5 className="card-title text-info">{needsRepairCount}</h5>
<p className="card-text">Needs Repair</p>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-4">
<div className="card border-secondary h-100">
<div className="card-body text-center">
<h5 className="card-title text-secondary">{missingCount}</h5>
<p className="card-text">Missing / Cannot Find</p>
</div>
</div>
</div>
{/* Items by Type */}
<div className="col-12">
<div className="card">
<div className="card-header">Items by Type</div>
<div className="card-body">
{Object.keys(byType).length === 0 ? (
<p className="text-muted mb-0">No items found.</p>
) : (
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th>Type</th>
<th className="text-end">Count</th>
</tr>
</thead>
<tbody>
{Object.entries(byType).map(([type, count]) => (
<tr key={type}>
<td style={{ textTransform: 'capitalize' }}>{type}</td>
<td className="text-end">{Number(count).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* Quick Links */}
<div className="col-12">
<div className="card">
<div className="card-header">Quick Actions</div>
<div className="card-body">
<div className="d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/classroom">
Classroom Equipment
</Link>
<Link className="btn btn-outline-primary" to={INVENTORY_BOOK_BASE}>
Books
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/office">
Office Supplies
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/kitchen">
Kitchen Supplies
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/movements">
Movements
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/summary">
Summary
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/reorder-suggestions">
Reorder Suggestions
</Link>
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/low-stock">
Low Stock
</Link>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchLowStock } from '../../api/inventory'
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
/**
* Low Stock Items — items where quantity <= reorder_point.
* Backend: GET /api/v1/inventory/low-stock
*/
export function InventoryLowStockPage() {
const [items, setItems] = useState<Record<string, unknown>[]>([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchLowStock()
.then((res) => {
if (res && typeof res === 'object') {
const o = res as Record<string, unknown>
const arr = Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : []
setItems(arr)
setCount(Number(o.count ?? arr.length))
}
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load low stock items.')
})
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
}, [load])
return (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Low Stock Items ({count})</h3>
<div className="d-flex gap-2">
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
Reorder Suggestions
</Link>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
{loading ? (
<p className="text-muted px-3">Loading</p>
) : items.length === 0 ? (
<div className="alert alert-success mx-3">
<strong>All stocked up!</strong> No items are below their reorder point.
</div>
) : (
<div className="card mx-3">
<div className="card-body table-responsive">
<table className="table table-striped align-middle" id="lowStockTable">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Category</th>
<th className="text-end">Current Qty</th>
<th className="text-end">Reorder Point</th>
<th className="text-end">Reorder Qty</th>
<th className="text-end">Lead Time (Days)</th>
<th>Preferred Supplier</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const i = item as Record<string, unknown>
const supplier = i.preferred_supplier as Record<string, unknown> | null
return (
<tr key={String(i.id)}>
<td>{String(i.name ?? '')}</td>
<td style={{ textTransform: 'capitalize' }}>{String(i.type ?? '')}</td>
<td>{String((i.category as Record<string, unknown>)?.name ?? i.category ?? '')}</td>
<td className="text-end text-danger fw-semibold">
{Number(i.quantity ?? 0).toLocaleString()}
</td>
<td className="text-end">{Number(i.reorder_point ?? 0).toLocaleString()}</td>
<td className="text-end">{Number(i.reorder_quantity ?? 0).toLocaleString() || '—'}</td>
<td className="text-end">{Number(i.lead_time_days ?? 0) || '—'}</td>
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
<td>
<Link
className="btn btn-sm btn-warning"
to={`/app/administrator/inventory/items/${String(i.id)}/adjust`}
>
Adjust Stock
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchReorderSuggestions } from '../../api/inventory'
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
/**
* Reorder Suggestions — calculated suggested order quantities for low-stock items.
* Backend: GET /api/v1/inventory/reorder-suggestions
*/
export function InventoryReorderSuggestionsPage() {
const [suggestions, setSuggestions] = useState<Record<string, unknown>[]>([])
const [count, setCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchReorderSuggestions()
.then((res) => {
if (res && typeof res === 'object') {
const o = res as Record<string, unknown>
const arr = Array.isArray(o.suggestions) ? (o.suggestions as Record<string, unknown>[]) : []
setSuggestions(arr)
setCount(Number(o.count ?? arr.length))
}
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load reorder suggestions.')
})
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
}, [load])
return (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Reorder Suggestions ({count})</h3>
<div className="d-flex gap-2">
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
Low Stock
</Link>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
{loading ? (
<p className="text-muted px-3">Loading</p>
) : suggestions.length === 0 ? (
<div className="alert alert-success mx-3">
<strong>All stocked up!</strong> No reorder suggestions at this time.
</div>
) : (
<div className="card mx-3">
<div className="card-body table-responsive">
<table className="table table-striped align-middle" id="reorderSuggestionsTable">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th className="text-end">Current Qty</th>
<th className="text-end">Reorder Point</th>
<th className="text-end">Suggested Order</th>
<th>Preferred Supplier</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{suggestions.map((s) => {
const supplier = s.preferred_supplier as Record<string, unknown> | null
return (
<tr key={String(s.id)}>
<td>{String(s.name ?? '')}</td>
<td>{String(s.category ?? '—')}</td>
<td className="text-end text-danger fw-semibold">
{Number(s.current_quantity ?? 0).toLocaleString()}
</td>
<td className="text-end">{Number(s.reorder_point ?? 0).toLocaleString()}</td>
<td className="text-end fw-semibold">
{Number(s.suggested_order_qty ?? 0).toLocaleString()}
</td>
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
<td>
<Link
className="btn btn-sm btn-warning"
to={`/app/administrator/inventory/items/${String(s.id)}/adjust`}
>
Adjust Stock
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,167 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchStockStatus } from '../../api/inventory'
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
/**
* Stock Status — all items with status: ok/low_stock/out_of_stock.
* Backend: GET /api/v1/inventory/stock-status
*/
export function InventoryStockStatusPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [items, setItems] = useState<Record<string, unknown>[]>([])
const [counts, setCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchStockStatus(searchParams)
.then((res) => {
if (res && typeof res === 'object') {
const o = res as Record<string, unknown>
setItems(Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : [])
setCounts((o.counts as Record<string, number>) ?? {})
}
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load stock status.')
})
.finally(() => setLoading(false))
}, [searchParams])
useEffect(() => {
load()
}, [load])
function onTypeFilter(v: string) {
const next = new URLSearchParams(searchParams)
if (v) next.set('type', v)
else next.delete('type')
setSearchParams(next)
}
const currentType = searchParams.get('type') ?? ''
return (
<div className="container-fluid px-0 mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
<h3 className="mb-0">Stock Status</h3>
<div className="d-flex gap-2">
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
Low Stock
</Link>
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
Reorder Suggestions
</Link>
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
Back to Items
</Link>
</div>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
{loading ? (
<p className="text-muted px-3">Loading</p>
) : (
<>
{/* Summary Badges */}
<div className="d-flex gap-3 mb-3 px-3 flex-wrap">
<span className="badge bg-success fs-6">
OK: {counts.ok ?? 0}
</span>
<span className="badge bg-warning fs-6">
Low Stock: {counts.low_stock ?? 0}
</span>
<span className="badge bg-danger fs-6">
Out of Stock: {counts.out_of_stock ?? 0}
</span>
</div>
<div className="card mx-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>All Items</span>
<select
className="form-select form-select-sm"
style={{ minWidth: 180 }}
value={currentType}
onChange={(e) => onTypeFilter(e.target.value)}
>
<option value="">All Types</option>
<option value="book">Books</option>
<option value="classroom">Classroom</option>
<option value="office">Office</option>
<option value="kitchen">Kitchen</option>
</select>
</div>
<div className="card-body table-responsive">
<table className="table table-striped align-middle" id="stockStatusTable">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Category</th>
<th className="text-end">Quantity</th>
<th className="text-end">Reorder Point</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-muted py-4">
No items found.
</td>
</tr>
) : (
items.map((item) => {
const status = String(item.status ?? 'ok')
const badgeClass =
status === 'out_of_stock'
? 'bg-danger'
: status === 'low_stock'
? 'bg-warning'
: 'bg-success'
const badgeLabel =
status === 'out_of_stock'
? 'Out of Stock'
: status === 'low_stock'
? 'Low Stock'
: 'OK'
return (
<tr key={String(item.id)}>
<td>{String(item.name ?? '')}</td>
<td style={{ textTransform: 'capitalize' }}>{String(item.type ?? '')}</td>
<td>{String(item.category ?? '—')}</td>
<td className="text-end">{Number(item.quantity ?? 0).toLocaleString()}</td>
<td className="text-end">
{item.reorder_point != null ? Number(item.reorder_point).toLocaleString() : '—'}
</td>
<td>
<span className={`badge ${badgeClass}`}>{badgeLabel}</span>
</td>
<td>
<Link
className="btn btn-sm btn-outline-primary me-1"
to={`/app/administrator/inventory/items/${String(item.id)}/adjust`}
>
Adjust
</Link>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
)
}
+12 -7
View File
@@ -1,6 +1,7 @@
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 { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchInventorySummary } from '../../api/inventory' import { fetchInventorySummary } from '../../api/inventory'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar' import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { INVENTORY_BOOK_BASE } from './inventoryPaths' import { INVENTORY_BOOK_BASE } from './inventoryPaths'
@@ -20,6 +21,10 @@ export function InventorySummaryPage() {
const [schoolYears, setSchoolYears] = useState<string[]>([]) const [schoolYears, setSchoolYears] = useState<string[]>([])
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 { options, currentYear: canonicalCurrentYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: selectedYear,
})
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true) setLoading(true)
@@ -94,19 +99,19 @@ export function InventorySummaryPage() {
value={ value={
selectedYear.toLowerCase() === 'all' selectedYear.toLowerCase() === 'all'
? 'all' ? 'all'
: selectedYear === currentYear && !searchParams.get('school_year') : selectedYear === (currentYear || canonicalCurrentYear) && !searchParams.get('school_year')
? '__current__' ? '__current__'
: selectedYear : selectedYear
} }
onChange={(e) => onYearFilter(e.target.value)} onChange={(e) => onYearFilter(e.target.value)}
> >
<option value="__current__">Current ({currentYear || '—'})</option> <option value="__current__">Current ({currentYear || canonicalCurrentYear || '—'})</option>
<option value="all">All Years</option> <option value="all">All Years</option>
{schoolYears {options
.filter((y) => y !== currentYear) .filter((option) => option.value !== (currentYear || canonicalCurrentYear))
.map((y) => ( .map((option) => (
<option key={y} value={y}> <option key={option.value} value={option.value}>
{y} {option.label}
</option> </option>
))} ))}
</select> </select>
@@ -1,6 +1,7 @@
import { type FormEvent, useCallback, useEffect, useState } from 'react' import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchTeacherBookDistribute, postTeacherBookDistribute } from '../../api/inventory' import { fetchTeacherBookDistribute, postTeacherBookDistribute } from '../../api/inventory'
type BookGroup = { label: string; items: Record<string, unknown>[] } type BookGroup = { label: string; items: Record<string, unknown>[] }
@@ -23,6 +24,9 @@ export function TeacherBookDistributePage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true) setLoading(true)
@@ -74,6 +78,8 @@ export function TeacherBookDistributePage() {
setSelected((prev) => ({ ...prev, [k]: checked })) setSelected((prev) => ({ ...prev, [k]: checked }))
} }
const effectiveSchoolYear = schoolYear || selectedYear
function checkAll(on: boolean) { function checkAll(on: boolean) {
const next: Record<string, boolean> = {} const next: Record<string, boolean> = {}
if (on) { if (on) {
@@ -179,7 +185,7 @@ export function TeacherBookDistributePage() {
<div className="alert alert-info py-2"> <div className="alert alert-info py-2">
<div className="d-flex justify-content-between flex-wrap gap-2"> <div className="d-flex justify-content-between flex-wrap gap-2">
<div> <div>
<strong>School Year:</strong> {schoolYear}, <strong>Semester:</strong> {semester} <strong>School Year:</strong> {effectiveSchoolYear || '—'}, <strong>Semester:</strong> {semester}
</div> </div>
<div> <div>
<strong>On Hand:</strong> {onHand} <strong>On Hand:</strong> {onHand}
@@ -239,7 +245,7 @@ export function TeacherBookDistributePage() {
{String(student.semester ?? semester)} {String(student.semester ?? semester)}
</td> </td>
<td style={{ textAlign: 'center' }}> <td style={{ textAlign: 'center' }}>
{String(student.school_year ?? schoolYear)} {String(student.school_year ?? effectiveSchoolYear)}
</td> </td>
<td style={{ textAlign: 'center' }}> <td style={{ textAlign: 'center' }}>
{given ? ( {given ? (
+4
View File
@@ -5,12 +5,16 @@ export { InventoryBookIndexPage } from './InventoryBookIndexPage'
export { InventoryClassroomAuditPage } from './InventoryClassroomAuditPage' export { InventoryClassroomAuditPage } from './InventoryClassroomAuditPage'
export { InventoryClassroomFormPage } from './InventoryClassroomFormPage' export { InventoryClassroomFormPage } from './InventoryClassroomFormPage'
export { InventoryClassroomIndexPage } from './InventoryClassroomIndexPage' export { InventoryClassroomIndexPage } from './InventoryClassroomIndexPage'
export { InventoryDashboardPage } from './InventoryDashboardPage'
export { InventoryEditEntryPage } from './InventoryEditEntryPage' export { InventoryEditEntryPage } from './InventoryEditEntryPage'
export { InventoryKitchenFormPage } from './InventoryKitchenFormPage' export { InventoryKitchenFormPage } from './InventoryKitchenFormPage'
export { InventoryKitchenIndexPage } from './InventoryKitchenIndexPage' export { InventoryKitchenIndexPage } from './InventoryKitchenIndexPage'
export { InventoryLowStockPage } from './InventoryLowStockPage'
export { InventoryMovementFormPage } from './InventoryMovementFormPage' export { InventoryMovementFormPage } from './InventoryMovementFormPage'
export { InventoryMovementsIndexPage } from './InventoryMovementsIndexPage' export { InventoryMovementsIndexPage } from './InventoryMovementsIndexPage'
export { InventoryOfficeFormPage } from './InventoryOfficeFormPage' export { InventoryOfficeFormPage } from './InventoryOfficeFormPage'
export { InventoryOfficeIndexPage } from './InventoryOfficeIndexPage' export { InventoryOfficeIndexPage } from './InventoryOfficeIndexPage'
export { InventoryReorderSuggestionsPage } from './InventoryReorderSuggestionsPage'
export { InventoryStockStatusPage } from './InventoryStockStatusPage'
export { InventorySummaryPage } from './InventorySummaryPage' export { InventorySummaryPage } from './InventorySummaryPage'
export { TeacherBookDistributePage } from './TeacherBookDistributePage' export { TeacherBookDistributePage } from './TeacherBookDistributePage'
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
fetchInvoiceManagement, fetchInvoiceManagement,
type InvoiceManagementRow, type InvoiceManagementRow,
@@ -37,6 +38,10 @@ export function InvoiceManagementPage() {
const [generating, setGenerating] = useState<number | null>(null) const [generating, setGenerating] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [toast, setToast] = useState<{ ok: boolean; message: string } | null>(null) const [toast, setToast] = useState<{ ok: boolean; message: string } | null>(null)
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
const load = useCallback(async (year?: string) => { const load = useCallback(async (year?: string) => {
setLoading(true) setLoading(true)
@@ -47,7 +52,7 @@ export function InvoiceManagementPage() {
const data = await fetchInvoiceManagement(sp) const data = await fetchInvoiceManagement(sp)
const years = Array.isArray(data.schoolYears) ? data.schoolYears : [] const years = Array.isArray(data.schoolYears) ? data.schoolYears : []
setSchoolYears(years) setSchoolYears(years)
const sel = data.schoolYear ?? years[0] ?? '' const sel = data.schoolYear ?? years[0] ?? defaultYear
setSchoolYear(sel) setSchoolYear(sel)
setInvoices(Array.isArray(data.invoices) ? data.invoices : []) setInvoices(Array.isArray(data.invoices) ? data.invoices : [])
} catch (e: unknown) { } catch (e: unknown) {
@@ -56,7 +61,7 @@ export function InvoiceManagementPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [defaultYear])
useEffect(() => { useEffect(() => {
void load() void load()
@@ -133,9 +138,9 @@ export function InvoiceManagementPage() {
value={schoolYear} value={schoolYear}
onChange={(e) => void onYearChange(e.target.value)} onChange={(e) => void onYearChange(e.target.value)}
> >
{schoolYears.map((y) => ( {options.map((option) => (
<option key={y} value={y}> <option key={option.value} value={option.value}>
{y} {option.label}
</option> </option>
))} ))}
</select> </select>
+588 -61
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { ParentParityShell } from './ParentParityShell' import { 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>
+9 -13
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useCallback, useEffect, useState } from 'react' import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
fetchChargeInvoicesForParent, fetchChargeInvoicesForParent,
fetchExtraChargesPage, fetchExtraChargesPage,
@@ -33,6 +34,10 @@ export function ExtraChargesPage() {
const [invoiceOptions, setInvoiceOptions] = useState<Array<{ id?: number; invoice_number?: string }>>([]) const [invoiceOptions, setInvoiceOptions] = useState<Array<{ id?: number; invoice_number?: string }>>([])
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [selectedParentId, setSelectedParentId] = useState<string>('') const [selectedParentId, setSelectedParentId] = useState<string>('')
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: activeYear || schoolYear,
})
useEffect(() => { useEffect(() => {
setActiveYear(schoolYear) setActiveYear(schoolYear)
@@ -101,15 +106,6 @@ export function ExtraChargesPage() {
let pageTotal = 0 let pageTotal = 0
for (const r of rows) pageTotal += Number(r.amount ?? 0) for (const r of rows) pageTotal += Number(r.amount ?? 0)
const years =
schoolYears.length > 0
? schoolYears
: (() => {
const out: string[] = []
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
return out
})()
return ( return (
<div className="container-fluid py-4"> <div className="container-fluid py-4">
{msg ? <div className="alert alert-info">{msg}</div> : null} {msg ? <div className="alert alert-info">{msg}</div> : null}
@@ -130,12 +126,12 @@ export function ExtraChargesPage() {
id="schoolYearSel" id="schoolYearSel"
className="form-select form-select-sm rounded-pill px-3" className="form-select form-select-sm rounded-pill px-3"
style={{ minWidth: 180 }} style={{ minWidth: 180 }}
value={activeYear || years[0] || ''} value={activeYear || defaultYear}
onChange={onYearChange} onChange={onYearChange}
> >
{years.map((y) => ( {options.map((option) => (
<option key={y} value={y}> <option key={option.value} value={option.value}>
{y} {option.label}
</option> </option>
))} ))}
</select> </select>
+10 -14
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react' import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
downloadFinancialReportCsv, downloadFinancialReportCsv,
fetchFinancialReportDetailed, fetchFinancialReportDetailed,
@@ -29,6 +30,10 @@ export function FinancialReportPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [lastRef, setLastRef] = useState<string | null>(null) const [lastRef, setLastRef] = useState<string | null>(null)
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
const hydrate = useCallback(() => { const hydrate = useCallback(() => {
setLoading(true) setLoading(true)
@@ -133,15 +138,6 @@ export function FinancialReportPage() {
const totalCheck = Number(pt.total_check ?? 0) const totalCheck = Number(pt.total_check ?? 0)
const grandTotal = Number(pt.total_all ?? totalCash + totalCredit + totalCheck) const grandTotal = Number(pt.total_all ?? totalCash + totalCredit + totalCheck)
const yearOptions =
schoolYears.length > 0
? schoolYears
: (() => {
const out: string[] = []
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
return out
})()
return ( return (
<div className="container-fluid mt-4"> <div className="container-fluid mt-4">
<h2 className="text-center mt-4 mb-3">Detailed Financial Report</h2> <h2 className="text-center mt-4 mb-3">Detailed Financial Report</h2>
@@ -158,12 +154,12 @@ export function FinancialReportPage() {
name="school_year" name="school_year"
className="form-select form-select-sm rounded-pill px-3" className="form-select form-select-sm rounded-pill px-3"
style={{ minWidth: 180 }} style={{ minWidth: 180 }}
value={schoolYear || yearOptions[0] || ''} value={schoolYear || defaultYear}
onChange={(e) => setSchoolYear(e.target.value)} onChange={(e) => setSchoolYear(e.target.value)}
> >
{yearOptions.map((sy) => ( {options.map((option) => (
<option key={sy} value={sy}> <option key={option.value} value={option.value}>
{sy} {option.label}
</option> </option>
))} ))}
</select> </select>
@@ -221,7 +217,7 @@ export function FinancialReportPage() {
</button> </button>
<Link <Link
className="btn btn-info" className="btn btn-info"
to={`/app/administrator/payment/financial-report-summary?school_year=${encodeURIComponent(schoolYear || yearOptions[0] || '')}`} to={`/app/administrator/payment/financial-report-summary?school_year=${encodeURIComponent(schoolYear || defaultYear)}`}
> >
Display Summary Report Display Summary Report
</Link> </Link>
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
downloadFinancialSummaryPdf, downloadFinancialSummaryPdf,
fetchFinancialReportSummary, fetchFinancialReportSummary,
@@ -19,6 +20,9 @@ export function FinancialReportSummaryPage() {
const [d, setD] = useState<FinancialSummaryJson | null>(null) const [d, setD] = useState<FinancialSummaryJson | null>(null)
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 { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear || urlYear,
})
function load(y: string) { function load(y: string) {
setLoading(true) setLoading(true)
@@ -87,7 +91,7 @@ export function FinancialReportSummaryPage() {
name="school_year" name="school_year"
className="form-select form-select-sm rounded-pill px-3" className="form-select form-select-sm rounded-pill px-3"
style={{ minWidth: 180 }} style={{ minWidth: 180 }}
value={schoolYear} value={schoolYear || selectedYear}
onChange={(e) => { onChange={(e) => {
const y = e.target.value const y = e.target.value
setSchoolYear(y) setSchoolYear(y)
@@ -96,15 +100,11 @@ export function FinancialReportSummaryPage() {
setSearchParams(next) setSearchParams(next)
}} }}
> >
{(() => { {(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
const years: string[] = [] <option key={option.value} value={option.value}>
for (let y = new Date().getFullYear(); y >= 2020; y--) years.push(`${y}-${y + 1}`) {option.label}
return years.map((y) => ( </option>
<option key={y} value={y}> ))}
{y}
</option>
))
})()}
</select> </select>
</form> </form>
<div className="d-flex align-items-center gap-2 flex-nowrap flex-shrink-0"> <div className="d-flex align-items-center gap-2 flex-nowrap flex-shrink-0">
+39 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { 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.')
} }
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { import {
downloadBatchCsv, downloadBatchCsv,
downloadReimbursementsExport, downloadReimbursementsExport,
@@ -92,6 +93,10 @@ export function ReimbursementsIndexPage() {
} }
const schoolYears = data?.schoolYears ?? [] const schoolYears = data?.schoolYears ?? []
const { options } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
const users = data?.users ?? [] const users = data?.users ?? []
const batchSummaries = (data?.batchSummaries ?? []) as BatchSummaryRow[] const batchSummaries = (data?.batchSummaries ?? []) as BatchSummaryRow[]
const batchDetails = data?.batchDetails ?? {} const batchDetails = data?.batchDetails ?? {}
@@ -188,9 +193,9 @@ export function ReimbursementsIndexPage() {
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<select name="school_year" className="form-select" defaultValue={schoolYear}> <select name="school_year" className="form-select" defaultValue={schoolYear}>
<option value="">All</option> <option value="">All</option>
{schoolYears.map((sy) => ( {options.map((option) => (
<option key={sy} value={sy}> <option key={option.value} value={option.value}>
{sy} {option.label}
</option> </option>
))} ))}
</select> </select>
+12 -8
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchCombinedReport } from '../../api/reportsCombined' import { fetchCombinedReport } from '../../api/reportsCombined'
import { REPORT_COMBINED_PATH } from './reportPaths' import { REPORT_COMBINED_PATH } from './reportPaths'
@@ -275,6 +276,10 @@ export function CombinedReportPage() {
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none' as const, userSelect: 'none' as const,
} }
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
preferredYear: selectedYear,
})
const schoolYearOptions = options.length > 0 ? options : [{ value: defaultYear, label: defaultYear }]
return ( return (
<div className="container-fluid py-4"> <div className="container-fluid py-4">
@@ -289,14 +294,13 @@ export function CombinedReportPage() {
<form className="row g-3 mb-4 align-items-end" onSubmit={applyFilter}> <form className="row g-3 mb-4 align-items-end" onSubmit={applyFilter}>
<div className="col-md-6 col-lg-4"> <div className="col-md-6 col-lg-4">
<label htmlFor="school_year" className="form-label">School Year</label> <label htmlFor="school_year" className="form-label">School Year</label>
<input <select id="school_year" name="school_year" className="form-select" defaultValue={selectedYear || defaultYear}>
id="school_year" {schoolYearOptions.map((option) => (
type="text" <option key={option.value} value={option.value}>
name="school_year" {option.label}
className="form-control" </option>
defaultValue={selectedYear} ))}
placeholder="e.g. 2025-2026" </select>
/>
</div> </div>
<div className="col-md-6 col-lg-4"> <div className="col-md-6 col-lg-4">
+7 -2
View File
@@ -5,6 +5,7 @@ import {
fetchBadgeScanLogs, fetchBadgeScanLogs,
type BadgeScanLogRow, type BadgeScanLogRow,
} from '../../api/badgeScan' } from '../../api/badgeScan'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
function formatDateTime(raw: string | null): string { function formatDateTime(raw: string | null): string {
if (!raw) return '—' if (!raw) return '—'
@@ -80,6 +81,10 @@ export function BadgeScanLogsPage() {
() => [...new Set(logs.map((r) => r.school_year).filter(Boolean))].sort().reverse() as string[], () => [...new Set(logs.map((r) => r.school_year).filter(Boolean))].sort().reverse() as string[],
[logs], [logs],
) )
const { options } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
return ( return (
<div className="container-fluid px-0"> <div className="container-fluid px-0">
@@ -94,8 +99,8 @@ export function BadgeScanLogsPage() {
<label htmlFor="bsl-school-year" className="form-label small mb-0">School Year</label> <label htmlFor="bsl-school-year" className="form-label small mb-0">School Year</label>
<select id="bsl-school-year" name="school_year" className="form-select form-select-sm" defaultValue={schoolYear}> <select id="bsl-school-year" name="school_year" className="form-select form-select-sm" defaultValue={schoolYear}>
<option value=""> All </option> <option value=""> All </option>
{schoolYears.map((sy) => ( {options.map((option) => (
<option key={sy} value={sy}>{sy}</option> <option key={option.value} value={option.value}>{option.label}</option>
))} ))}
</select> </select>
</div> </div>
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchScorePrediction, type ScorePredictionStudent } from '../../api/scoreAnalysis' import { fetchScorePrediction, type ScorePredictionStudent } from '../../api/scoreAnalysis'
import { FailRiskLabel, StatusBadge, TrophyChanceLabel } from './scorePredictionUi' import { FailRiskLabel, StatusBadge, TrophyChanceLabel } from './scorePredictionUi'
@@ -15,6 +16,9 @@ export function ScorePredictionPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof fetchScorePrediction>> | null>(null) const [data, setData] = useState<Awaited<ReturnType<typeof fetchScorePrediction>> | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYearParam,
})
useEffect(() => { useEffect(() => {
setError(null) setError(null)
@@ -125,6 +129,7 @@ export function ScorePredictionPage() {
} }
const syDefault = data?.school_year ?? schoolYearParam const syDefault = data?.school_year ?? schoolYearParam
const schoolYearOptions = options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]
const statusColors = ['#198754', '#dc3545', '#0d6efd', '#ffc107', '#6c757d'] const statusColors = ['#198754', '#dc3545', '#0d6efd', '#ffc107', '#6c757d']
const riskColors = ['#198754', '#ffc107', '#dc3545', '#6610f2', '#adb5bd'] const riskColors = ['#198754', '#ffc107', '#dc3545', '#6610f2', '#adb5bd']
@@ -148,13 +153,13 @@ export function ScorePredictionPage() {
<label htmlFor="school_year" className="form-label"> <label htmlFor="school_year" className="form-label">
School Year School Year
</label> </label>
<input <select className="form-select" id="school_year" name="school_year" defaultValue={syDefault || selectedYear}>
type="text" {schoolYearOptions.map((option) => (
className="form-control" <option key={option.value} value={option.value}>
id="school_year" {option.label}
name="school_year" </option>
defaultValue={syDefault} ))}
/> </select>
</div> </div>
<div className="col-md-6 col-lg-4"> <div className="col-md-6 col-lg-4">
<label htmlFor="class_section_id" className="form-label"> <label htmlFor="class_section_id" className="form-label">
+12 -8
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchSlipPreviewList, type SlipPreviewRow } from '../../api/slips' import { fetchSlipPreviewList, type SlipPreviewRow } from '../../api/slips'
import { displaySemester, formatPreviewDateKey } from './slipFormatting' import { displaySemester, formatPreviewDateKey } from './slipFormatting'
import { SLIPS_PRINT_PATH } from './slipPaths' import { SLIPS_PRINT_PATH } from './slipPaths'
@@ -60,6 +61,9 @@ export function SlipPreviewListPage() {
key: 'student_name', key: 'student_name',
direction: 'asc', direction: 'asc',
}) })
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => { useEffect(() => {
setError(null) setError(null)
@@ -109,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
}) })
} }
@@ -159,13 +163,13 @@ export function SlipPreviewListPage() {
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}> <form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}>
<div className="col-md-4"> <div className="col-md-4">
<label className="form-label">School Year</label> <label className="form-label">School Year</label>
<input <select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
type="text" {(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
name="school_year" <option key={option.value} value={option.value}>
className="form-control" {option.label}
defaultValue={schoolYear} </option>
placeholder="e.g. 2025-2026" ))}
/> </select>
</div> </div>
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">Semester</label> <label className="form-label">Semester</label>
+30 -5
View File
@@ -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>
+17 -5
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More