Compare commits

..

18 Commits

Author SHA1 Message Date
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
73 changed files with 6377 additions and 1019 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_ORIGIN` | — | (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` |
+153
View File
@@ -0,0 +1,153 @@
# 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: Cache npm dependencies
uses: actions/cache@v4
with:
path: node_modules/
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: 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: Cache npm dependencies
uses: actions/cache@v4
with:
path: node_modules/
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_ORIGIN: ${{ vars.VITE_API_ORIGIN || '' }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
# ──────────────────────────────────────────────
# DEPLOY (manual): to shared hosting via SSH
# ──────────────────────────────────────────────
deploy:
name: Deploy to shared hosting
runs-on: ubuntu-latest
container:
image: alpine:3.20
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 SSH and rsync
run: |
apk add --no-cache openssh-client sshpass rsync
- name: Download built artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy built files to shared hosting
run: |
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
-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
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- client_node_modules:/app/node_modules
environment:
# 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:
- 'host.docker.internal:host-gateway'
+43 -19
View File
@@ -1541,9 +1541,9 @@
}
},
"node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -1888,9 +1888,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2033,6 +2033,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -2109,13 +2121,14 @@
}
},
"node_modules/axios": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
@@ -2480,7 +2493,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3259,6 +3271,19 @@
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -3933,7 +3958,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -4429,9 +4453,9 @@
}
},
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -4451,12 +4475,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.2"
"react-router": "7.17.0"
},
"engines": {
"node": ">=20.0.0"
+1
View File
@@ -4,6 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"test": "echo \"No unit tests configured yet\" && exit 0",
"test:api": "node scripts/test-api.mjs",
"gen:view-meta": "node scripts/gen-view-route-meta.mjs",
"dev": "vite",
+41
View File
@@ -77,6 +77,11 @@ const ViewRouteSitemapPage = lazy(() =>
const ViewRoutePlaceholderPage = lazy(() =>
import('./pages/ViewRoutePlaceholderPage').then((m) => ({ default: m.ViewRoutePlaceholderPage })),
)
const SchoolYearsManagementPage = lazy(() =>
import('./pages/administrator/SchoolYearsManagementPage').then((m) => ({
default: m.SchoolYearsManagementPage,
})),
)
const NavBuilderPage = lazy(() =>
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
)
@@ -274,6 +279,12 @@ const AdministratorAbsencePage = lazy(() =>
const AttendanceTrackingPage = lazy(() =>
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(() =>
import('./pages/attendance').then((m) => ({ default: m.ComposeAttendanceEmailPage })),
)
@@ -502,6 +513,11 @@ const TeacherPrintRequestsPage = lazy(() =>
const CertificatesPage = lazy(() =>
import('./pages/certificates/CertificatesPage').then((m) => ({ default: m.CertificatesPage })),
)
const CertificatesAuditLogPage = lazy(() =>
import('./pages/certificates/CertificatesAuditLogPage').then((m) => ({
default: m.CertificatesAuditLogPage,
})),
)
const StickerFormPage = lazy(() =>
import('./pages/printables/StickerFormPage').then((m) => ({ default: m.StickerFormPage })),
)
@@ -616,6 +632,21 @@ const ScorePredictionPage = lazy(() =>
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(() =>
import('./pages/slips/SlipPreviewListPage').then((m) => ({ default: m.SlipPreviewListPage })),
)
@@ -1073,6 +1104,9 @@ export default function App() {
<Route path="administrator/absence" element={<AdministratorAbsencePage />} />
<Route path="administrator/attendance/admins-form" element={<AdminsAttendanceFormPage />} />
<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/early-dismissals" element={<EarlyDismissalsPage />} />
<Route path="administrator/attendance/early-dismissals/new" element={<EarlyDismissalsAddPage />} />
@@ -1097,6 +1131,7 @@ export default function App() {
<Route path="administrator/communications" element={<CommunicationsIndexPage />} />
<Route path="administrator/configuration_view" element={<ConfigurationViewPage />} />
<Route path="administrator/configuration" element={<Navigate to="configuration_view" replace />} />
<Route path="administrator/school-years" element={<SchoolYearsManagementPage />} />
<Route path="administrator/discounts" element={<DiscountListPage />} />
<Route path="administrator/discounts/create" element={<DiscountCreatePage />} />
<Route path="administrator/discounts/apply" element={<DiscountApplyVoucherPage />} />
@@ -1259,10 +1294,16 @@ export default function App() {
element={<ReportCardManagementPage />}
/>
<Route path="administrator/certificates" element={<CertificatesPage />} />
<Route path="administrator/certificates/log" element={<CertificatesAuditLogPage />} />
<Route
path="administrator/score-analysis/score-prediction"
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/print" element={<LateSlipPrintPage />} />
<Route path="slips/print" element={<LateSlipPrintPage />} />
@@ -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),
}),
)
}
+122 -32
View File
@@ -2,64 +2,154 @@ import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import type { ApiEnvelope } from './types'
export type CertClassSection = {
class_section_id: number
class_section_name: string
export type CertificateDecisionRow = {
decision: string
source: string
notes: string
year_score: number | null
}
export type CertStudent = {
id: number
export type CertificateStudentRow = {
student_id: number
firstname: 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 = {
class_sections: CertClassSection[]
students: CertStudent[]
export type CertificateSectionRow = {
section_id: number
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
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?: {
school_year?: string
class_section_id?: string | number
}): Promise<ApiEnvelope<CertFormOptions>> {
export type CertificateAuditRecord = {
certificate_number: string
student_name: string
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}`)
return headers
}
function withQuery(path: string, params?: Record<string, string | number | null | undefined>) {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.class_section_id != null && String(params.class_section_id) !== '')
qs.set('class_section_id', String(params.class_section_id))
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch<ApiEnvelope<CertFormOptions>>(`/api/v1/administrator/certificates/form-options${suffix}`)
Object.entries(params ?? {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== '') {
qs.set(key, String(value))
}
})
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: {
student_ids: number[]
cert_date: string
class_section_id?: number | string | null
class_section_id?: number | null
school_year?: string | null
}): 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'), {
method: 'POST',
headers,
headers: (() => {
const headers = authHeaders('application/pdf,*/*')
headers.set('Content-Type', 'application/json')
return headers
})(),
body: JSON.stringify(payload),
})
if (!res.ok) {
const errBody: unknown = await res.json().catch(() => null)
const msg =
const message =
typeof errBody === 'object' && errBody !== null && 'message' in errBody
? 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 new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
return res.blob()
}
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()
}
+35 -3
View File
@@ -11,6 +11,25 @@ export function setStoredToken(token: string | null): void {
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 class ApiHttpError extends Error {
readonly status: number
readonly body: unknown
@@ -30,24 +49,36 @@ export async function apiFetch<T>(
): Promise<T> {
const attachAuth = opts.attachAuth !== false
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()
if (attachAuth && token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
const url = apiUrl(path)
let res: Response
try {
res = await fetch(url, { ...init, headers })
res = await fetch(url, {
...init,
headers,
})
} catch (e) {
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_ORIGIN is set.'
: ''
const reason = e instanceof Error ? e.message : 'Failed to fetch'
throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null)
}
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
@@ -55,6 +86,7 @@ export async function apiFetch<T>(
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
}
+60 -3
View File
@@ -4,7 +4,7 @@
*/
import { apiFetch } from './http'
const BASE = '/api/v1/administrator/inventory'
const BASE = '/api/v1/inventory'
function q(searchParams: URLSearchParams): string {
const s = searchParams.toString()
@@ -18,7 +18,9 @@ export async function fetchInventoryIndex(
kind: InventoryKind,
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/${kind}${q(searchParams)}`)
const params = new URLSearchParams(searchParams)
params.set('type', kind)
return apiFetch(`${BASE}/items?${params.toString()}`)
}
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
@@ -92,7 +94,11 @@ export async function postClassroomAudit(
}
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 apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`)
}
export async function fetchMovements(searchParams: URLSearchParams): Promise<unknown> {
@@ -156,3 +162,54 @@ export async function postTeacherBookDistribute(body: Record<string, unknown>):
body: JSON.stringify(body),
})
}
/* ─── New Inventory Improvement API Calls ─── */
/** Fetch low-stock items (quantity <= reorder_point). */
export async function fetchLowStock(): Promise<unknown> {
return apiFetch(`${BASE}/low-stock`)
}
/** Fetch reorder suggestions for low-stock items. */
export async function fetchReorderSuggestions(): Promise<unknown> {
return apiFetch(`${BASE}/reorder-suggestions`)
}
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
export async function fetchStockStatus(
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/stock-status${q(searchParams)}`)
}
/** Fetch inventory dashboard summary counts. */
export async function fetchInventoryDashboard(
searchParams: URLSearchParams,
): Promise<unknown> {
return 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 }),
})
}
+2 -2
View File
@@ -4,7 +4,7 @@
import { apiUrl } from '../lib/apiOrigin'
import { apiFetch, getStoredToken } from './http'
const BASE = '/api/v1/administrator/invoices'
const BASE = '/api/v1/finance/invoices'
function q(searchParams: URLSearchParams): string {
const s = searchParams.toString()
@@ -35,7 +35,7 @@ export async function fetchInvoiceManagement(
}
export async function postInvoiceGenerate(parentId: number): Promise<unknown> {
return apiFetch(`${BASE}/generate-for-parent`, {
return apiFetch(`${BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_id: parentId }),
+56 -5
View File
@@ -175,7 +175,17 @@ export async function fetchUnpaidParents(params?: {
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`/api/v1/administrator/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: {
@@ -287,6 +297,15 @@ export type FinancialDetailedJson = {
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: {
school_year?: string
date_from?: string
@@ -297,7 +316,10 @@ export async function fetchFinancialReportDetailed(params: {
if (params.date_from) qs.set('date_from', params.date_from)
if (params.date_to) qs.set('date_to', params.date_to)
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: {
@@ -309,7 +331,7 @@ export async function downloadFinancialReportCsv(params: {
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_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 token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
@@ -341,19 +363,48 @@ export type FinancialSummaryJson = {
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: {
school_year?: string
}): Promise<FinancialSummaryJson> {
const qs = new URLSearchParams()
if (params.school_year) qs.set('school_year', params.school_year)
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> {
const qs = new URLSearchParams()
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 token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
+3 -3
View File
@@ -1,10 +1,10 @@
/**
* 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'
const BASE = '/api/v1/administrator/refunds'
const BASE = '/api/v1/finance/refunds'
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?.page != null) qs.set('page', String(params.page))
const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<unknown>(`${BASE}/list${suffix}`)
const body = await apiFetch<unknown>(`${BASE}${suffix}`)
return normalizeListPayload(body)
}
+294
View File
@@ -0,0 +1,294 @@
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
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
net_balance_to_transfer: 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
credit_balance: number
amount_to_transfer: number
existing_transfer_conflict?: boolean
}
export type SchoolYearParentBalancePreview = {
rows: SchoolYearParentBalanceRow[]
summary: {
parents_with_non_zero_balance: number
parents_with_credit_balance: number
total_old_unpaid_balance: number
total_credit_balance: number
net_balance_to_transfer: 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 SaveSchoolYearPayload = {
name: string
start_date: string
end_date: string
}
export type CloseSchoolYearPayload = {
new_school_year: SaveSchoolYearPayload
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
}
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(
schoolYearId: number,
payload: SaveSchoolYearPayload,
): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.data) {
throw new Error('School year update returned no record.')
}
return response.data
}
export async function fetchSchoolYearSummary(schoolYearId: number): Promise<SchoolYearSummary> {
const response = await apiFetch<ApiSummaryResponse>(`/api/v1/school-years/${schoolYearId}/summary`)
if (!response.data) {
throw new Error('School year summary returned no data.')
}
return response.data
}
export async function validateSchoolYearClose(
schoolYearId: number,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseValidation> {
const response = await apiFetch<ApiValidationResponse>(
`/api/v1/school-years/${schoolYearId}/validate-close`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
if (!response.validation) {
throw new Error('School year validation returned no payload.')
}
return response.validation
}
export async function previewSchoolYearClose(
schoolYearId: number,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearClosePreview> {
const response = await apiFetch<ApiPreviewResponse>(
`/api/v1/school-years/${schoolYearId}/preview-close`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
if (!response.data) {
throw new Error('School year close preview returned no payload.')
}
return response.data
}
export async function closeSchoolYear(
schoolYearId: number,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseResult> {
const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/${schoolYearId}/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.data) {
throw new Error('School year close returned no payload.')
}
return response.data
}
export async function reopenSchoolYear(schoolYearId: number): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}/reopen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.data) {
throw new Error('School year reopen returned no payload.')
}
return response.data
}
export async function fetchSchoolYearParentBalances(
schoolYearId: number,
toSchoolYear?: string,
): Promise<SchoolYearParentBalancePreview> {
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
const response = await apiFetch<ApiParentBalancesResponse>(
`/api/v1/school-years/${schoolYearId}/parent-balances${query}`,
)
if (!response.data) {
throw new Error('Parent balances preview returned no payload.')
}
return response.data
}
export async function fetchSchoolYearPromotionPreview(
schoolYearId: number,
toSchoolYear?: string,
): Promise<SchoolYearPromotionPreview> {
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
const response = await apiFetch<ApiPromotionPreviewResponse>(
`/api/v1/school-years/${schoolYearId}/promotion-preview${query}`,
)
if (!response.data) {
throw new Error('Promotion preview returned no payload.')
}
return response.data
}
+362 -31
View File
@@ -23,7 +23,6 @@ import type {
GradingOverviewResponse,
IpBansResponse,
LandingPageResponse,
LoginFailure,
LoginResponse,
LoginSuccess,
LateSlipLogsResponse,
@@ -60,6 +59,13 @@ import type {
ParentProfileRecord,
ContactSubmitResponse,
CompetitionScoresEditResponse,
CompetitionWinnerAdminActionResponse,
CompetitionWinnerAdminFormResponse,
CompetitionWinnerAdminIndexResponse,
CompetitionWinnerAdminPreviewResponse,
CompetitionWinnerAdminScoresResponse,
CompetitionWinnerAdminWinnersResponse,
CompetitionWinnerExportQuizResponse,
CompetitionScoresIndexResponse,
ExamDraftAdminResponse,
PublicCompetitionIndexResponse,
@@ -89,36 +95,144 @@ import type {
EnrollmentWithdrawalPageResponse,
ExpensesListResponse,
ExpenseCreateFormResponse,
ExpenseDetail,
ExpenseEditFormResponse,
ExpenseUserOption,
FamilyLegacyImportMetaResponse,
FamilyLegacyImportResult,
FamilySearchResponse,
} 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(
email: string,
password: string,
): Promise<LoginResponse> {
const body = await apiFetch<unknown>(
'/api/v1/auth/login',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
},
{ attachAuth: false },
)
const loginPaths = ['/api/v1/auth/login', '/api/v1/login', '/api/login']
let body: unknown = null
let lastError: unknown = null
if (!body || typeof body !== 'object' || !('status' in body)) {
throw new Error('Invalid login response from API.')
for (const path of loginPaths) {
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) {
const fail = body as LoginFailure
return { status: false, message: fail.message }
if (lastError) throw lastError
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>> {
@@ -381,7 +495,8 @@ export async function fetchStaffMonthlyAttendanceOverview(params?: {
if (params?.semester) query.set('semester', params.semester)
if (params?.schoolYear) query.set('school_year', params.schoolYear)
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). */
@@ -392,7 +507,7 @@ export async function saveStaffMonthlyAttendanceCell(payload: Record<string, str
}> {
const body = new URLSearchParams()
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',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
@@ -884,6 +999,98 @@ export async function fetchCompetitionScoresDetail(
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(
id: number,
payload: { class_section_id?: number | null; scores: Record<string, number | string> },
@@ -912,8 +1119,9 @@ export async function notifyTeacherSubmissions(payload: {
)
}
export async function fetchSubjectCurriculum(): Promise<SubjectCurriculumResponse> {
return apiFetch<SubjectCurriculumResponse>('/api/v1/subjects/curriculum')
export async function fetchSubjectCurriculum(schoolYear?: string | null): Promise<SubjectCurriculumResponse> {
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<SubjectCurriculumResponse>(`/api/v1/subjects/curriculum${query}`)
}
export async function createSubjectCurriculum(payload: {
@@ -922,6 +1130,7 @@ export async function createSubjectCurriculum(payload: {
unit_number?: number | null
unit_title?: string | null
chapter_name: string
school_year?: string | null
}): Promise<ApiEnvelope<{ entry?: unknown }>> {
return apiFetch<ApiEnvelope<{ entry?: unknown }>>('/api/v1/subjects/curriculum', {
method: 'POST',
@@ -1038,12 +1247,16 @@ export async function openProtectedApiFile(path: string): Promise<void> {
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 token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiUrl(path), {
method: 'POST',
method: init.method ?? 'POST',
headers,
body: formData,
})
@@ -1192,6 +1405,68 @@ export async function fetchUsers(params?: {
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 {
if (!body || typeof body !== 'object') return body
const o = body as Record<string, unknown>
@@ -2024,10 +2299,19 @@ export async function fetchDiscountApplyContext(params?: {
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
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 }>(
`/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 }> {
@@ -2193,11 +2477,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> {
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(
@@ -2207,26 +2511,53 @@ export async function createExpense(
}
export async function fetchExpenseEdit(expenseId: number): Promise<ExpenseEditFormResponse> {
const body = await apiFetch<ExpenseEditFormResponse & { data?: ExpenseEditFormResponse }>(
`/api/v1/administrator/expenses/${expenseId}/edit`,
const [expenseBody, optionsBody] = await Promise.all([
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(
expenseId: number,
formData: FormData,
): 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: {
id: number
status: 'approved' | 'denied'
}): Promise<{ success?: boolean; error?: string }> {
return apiFetch(`/api/v1/administrator/expenses/update-status`, {
return apiFetch(`/api/v1/administrator/expenses/${payload.id}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify({ status: payload.status }),
})
}
+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)
}
+117
View File
@@ -639,6 +639,7 @@ export type UserListRow = {
firstname?: string | null
lastname?: string | null
email?: string | null
school_id?: string | number | null
status?: string | null
cellphone?: string | null
gender?: string | null
@@ -978,6 +979,7 @@ export type SubjectCurriculumEntryRow = {
unit_number?: number | string | null
unit_title?: string | null
chapter_name: string
school_year?: string | null
}
export type SubjectCurriculumResponse = {
@@ -985,6 +987,8 @@ export type SubjectCurriculumResponse = {
classes: SubjectCurriculumClassRow[]
entries: SubjectCurriculumEntryRow[]
subject_labels: Record<string, string>
school_year?: string | null
school_years?: string[]
}
export type PublicCompetitionRow = {
@@ -1054,6 +1058,119 @@ export type CompetitionScoresIndexResponse = {
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 = {
id?: number
student_id?: number
+16 -11
View File
@@ -1,5 +1,7 @@
import { Link } from 'react-router-dom'
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 = {
id?: string
@@ -61,19 +63,22 @@ export function CiAcademicFilter({
classSectionId,
onApply,
}: {
schoolYears?: Array<string | { school_year: string }>
schoolYears?: LegacySchoolYearValue[]
selectedYear?: string | null
selectedSemester?: string | null
classSectionId?: string | number | null
onApply?: (values: { schoolYear: string; semester: string; classSectionId?: string }) => void
}) {
const years = (schoolYears ?? []).map((year) => (typeof year === 'string' ? year : year.school_year))
const [schoolYear, setSchoolYear] = useState(selectedYear ?? years[0] ?? '')
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: selectedYear,
})
const [schoolYear, setSchoolYear] = useState(selectedYear ?? defaultYear)
const [semester, setSemester] = useState(selectedSemester ?? '')
useEffect(() => {
setSchoolYear(selectedYear ?? years[0] ?? '')
}, [selectedYear, years.join('|')])
setSchoolYear(selectedYear ?? defaultYear)
}, [defaultYear, selectedYear])
useEffect(() => {
setSemester(selectedSemester ?? '')
@@ -104,10 +109,10 @@ export function CiAcademicFilter({
value={schoolYear}
onChange={(event) => setSchoolYear(event.target.value)}
>
{years.length > 0 ? (
years.map((year) => (
<option key={year} value={year}>
{year}
{options.length > 0 ? (
options.map((year) => (
<option key={year.value} value={year.value}>
{year.label}
</option>
))
) : (
@@ -143,9 +148,9 @@ export function CiAcademicFilter({
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => {
setSchoolYear(years[0] ?? '')
setSchoolYear(defaultYear)
setSemester('')
onApply?.({ schoolYear: years[0] ?? '', semester: '', classSectionId: undefined })
onApply?.({ schoolYear: defaultYear, semester: '', classSectionId: undefined })
}}
>
Reset
+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,
}
}
+37 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { ApiHttpError } from '../api/http'
import { fetchNavMenu } from '../api/session'
import type { NavItem } from '../api/types'
import { useAuth } from '../auth/AuthProvider'
@@ -8,6 +9,25 @@ import {
spaPathFromCiUrl,
} 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[] {
const rawItems = Array.isArray(payload)
? (payload as NavItem[])
@@ -20,7 +40,7 @@ function normalizeNavItems(payload: unknown): NavItem[] {
if (rawItems.length === 0) return []
if (rawItems.some((item) => Array.isArray(item.children) && item.children.length > 0)) {
return rawItems
return sortNavTree(rawItems)
}
const byId = new Map<number, NavItem>()
@@ -38,7 +58,7 @@ function normalizeNavItems(payload: unknown): NavItem[] {
}
}
return tree
return sortNavTree(tree)
}
function branchContainsPath(item: NavItem, pathname: string): boolean {
@@ -59,8 +79,6 @@ function NavTree({ items, pathname }: { items: NavItem[]; pathname: string }) {
function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pathname: string }) {
const enabled = item.is_enabled !== 0
if (!enabled) return null
const label = item.label?.trim() || 'Menu'
const url = item.url?.trim() || ''
const external = isExternalNavUrl(url)
@@ -71,7 +89,7 @@ function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pa
<i className={`${item.icon_class} me-2`} aria-hidden />
) : 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 [open, setOpen] = useState(() => hasChildren && branchContainsPath(item, pathname))
@@ -81,6 +99,8 @@ function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pa
}
}, [hasChildren, item, pathname])
if (!enabled) return null
const linkInner = (
<>
{icon}
@@ -187,6 +207,17 @@ export function ManagementLayout() {
}
} catch (e) {
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([])
setMenuError(e instanceof Error ? e.message : 'Unable to load menu.')
}
@@ -195,7 +226,7 @@ export function ManagementLayout() {
return () => {
cancelled = true
}
}, [])
}, [location.hash, location.pathname, location.search, logout, navigate])
const navTree = useMemo(() => normalizeNavItems(items), [items])
+10 -7
View File
@@ -3,20 +3,23 @@ const PROD_API_ORIGIN = 'https://api.alrahmaisgl.org'
/**
* Laravel API base URL.
*
* **Vite dev:** same-origin `/api/...`; `vite.config.ts` proxies to the API (default `http://localhost:8080`).
* Override proxy target with **`VITE_PROXY_API`**. **`VITE_API_ORIGIN` is not used in dev** (avoids cross-origin from the browser).
* Dev defaults to same-origin `/api/...` so Vite can proxy requests to Laravel.
* Set `VITE_API_ORIGIN` when you want the browser to call Laravel directly, e.g.
* `VITE_API_ORIGIN=http://localhost:8000 npm run dev`.
*
* **Production build:** `VITE_API_ORIGIN` if set, else `PROD_API_ORIGIN`.
* If `VITE_API_ORIGIN` is not set in dev, `vite.config.ts` proxies `/api` to
* `VITE_PROXY_API` or `http://localhost:8000` by default.
*/
export function apiOrigin(): string {
if (import.meta.env.DEV) {
return ''
}
const v = import.meta.env.VITE_API_ORIGIN
if (typeof v === 'string' && v.trim()) {
return v.replace(/\/$/, '')
}
if (import.meta.env.DEV) {
return ''
}
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(/\/+$/, '')
if (!path) return '/app/home'
if (path === 'app' || path.startsWith('app/')) {
return `/${path}`
}
const querySuffix = path.includes('?') ? `?${path.slice(path.indexOf('?') + 1)}` : ''
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.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
}
+94 -69
View File
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 're
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import './AdminDailyAttendance.css'
import { ApiHttpError } from '../api/http'
import { CiAcademicFilter } from '../components/CiPartials'
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
import {
assignTeacherClass,
assignStudentClass,
@@ -1050,12 +1052,6 @@ export function AdminCalendarEditPage() {
/** 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() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
@@ -1063,12 +1059,15 @@ export function AdminCalendarViewPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const schoolYear = searchParams.get('school_year') ?? ''
const { options, currentYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
if (!schoolYear) {
setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }), { replace: true })
if (!schoolYear && currentYear) {
setSearchParams(new URLSearchParams({ school_year: currentYear }), { replace: true })
}
}, [])
}, [currentYear, schoolYear, setSearchParams])
async function load() {
setLoading(true)
@@ -1124,14 +1123,27 @@ export function AdminCalendarViewPage() {
>
<div>
<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 className="d-flex gap-2 align-self-end">
<button className="btn btn-secondary" type="submit">Apply</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }))}
onClick={() =>
setSearchParams(new URLSearchParams(currentYear ? { school_year: currentYear } : {}))
}
>
Reset
</button>
@@ -1307,6 +1319,10 @@ export function AdminClassAssignmentPage() {
key: 'school_id',
direction: 'asc',
})
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: selectedYear,
})
async function load(year?: string | null) {
setLoading(true)
@@ -1316,7 +1332,7 @@ export function AdminClassAssignmentPage() {
const classSections = data.classSections ?? []
setSections(classSections)
setSchoolYears(data.schoolYears ?? [])
setSelectedYear(data.selectedYear ?? data.schoolYear ?? '')
setSelectedYear(data.selectedYear ?? data.schoolYear ?? defaultYear)
setSelectedSemester(data.selectedSemester ?? data.semester ?? '')
setExpandedSections(new Set(classSections.length > 0 ? ['0'] : []))
setError(null)
@@ -1397,11 +1413,11 @@ export function AdminClassAssignmentPage() {
<select
className="form-select form-select-sm"
style={{ minWidth: 200 }}
value={selectedYear}
value={selectedYear || defaultYear}
onChange={(event) => setSelectedYear(event.target.value)}
>
{schoolYears.map((year) => (
<option key={year} value={year}>{year}</option>
{options.map((year) => (
<option key={year.value} value={year.value}>{year.label}</option>
))}
</select>
</div>
@@ -3644,15 +3660,10 @@ export function AdminRemovedStudentsPage() {
return (
<AdminParityShell title="Student Removal">
{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() }}>
<div className="col-sm-4 col-lg-3">
<label className="form-label">School Year</label>
<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>
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="row g-4">
<div className="col-xl-6">
<div className="card shadow-sm">
@@ -3793,7 +3804,8 @@ export function AdminSearchResultsPage() {
/** Section-level promotion totals — `GET /api/v1/students/promotion-totals` (JWT). */
export function AdminSectionsPromotionTotalsPage() {
const [schoolYear, setSchoolYear] = useState('')
const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -3824,26 +3836,14 @@ export function AdminSectionsPromotionTotalsPage() {
sections from these totals.
</p>
<div className="row g-2 align-items-end mb-3 flex-wrap">
<div className="col-sm-4 col-lg-3">
<label className="form-label">School year</label>
<input
className="form-control"
value={schoolYear}
onChange={(e) => setSchoolYear(e.target.value)}
placeholder="e.g. 2025-2026"
/>
</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 className="d-flex flex-wrap align-items-start justify-content-between gap-2 mb-3">
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<Link className="btn btn-outline-primary" to={`/app/administrator/sections/auto-distribute${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
Auto-distribute sections
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
@@ -3892,7 +3892,8 @@ export function AdminSectionsPromotionTotalsPage() {
}
export function AdminSectionsAutoDistributePage() {
const [schoolYear, setSchoolYear] = useState('')
const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [studentsPerSection, setStudentsPerSection] = useState(20)
const [rows, setRows] = useState<StudentPromotionTotalsRow[]>([])
const [loading, setLoading] = useState(true)
@@ -3941,15 +3942,16 @@ export function AdminSectionsAutoDistributePage() {
<AdminParityShell title="Auto-Distribute Students into Sections">
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-2 align-items-end mb-3">
<div className="col-sm-4 col-lg-3">
<label className="form-label">School Year</label>
<input className="form-control" value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} />
<div className="col-lg-6">
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
</div>
<div className="col-sm-4 col-lg-3">
<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)} />
</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>
<div className="table-responsive">
@@ -3980,6 +3982,7 @@ export function AdminSectionsAutoDistributePage() {
}
export function AdminStudentClassAssignmentPage() {
const [searchParams] = useSearchParams()
type StudentAssignmentSortKey =
| 'name'
| 'age'
@@ -3988,7 +3991,7 @@ export function AdminStudentClassAssignmentPage() {
| 'assigned_classes'
| 'registration_date'
| 'school_year'
const [schoolYear, setSchoolYear] = useState('')
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [students, setStudents] = useState<StudentAssignmentRow[]>([])
const [classes, setClasses] = useState<StudentAssignmentOption[]>([])
const [selectedStudentId, setSelectedStudentId] = useState<number | null>(null)
@@ -4103,11 +4106,10 @@ export function AdminStudentClassAssignmentPage() {
{message ? <div className="alert alert-info">{message}</div> : null}
<div className="row g-3 mb-4">
<div className="col-lg-4">
<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>
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
</div>
<div className="col-lg-8">
<div className="card shadow-sm">
@@ -4211,6 +4213,7 @@ export function AdminStudentClassAssignmentPage() {
}
export function AdminStudentProfilesPage() {
const [searchParams] = useSearchParams()
type StudentProfileSortKey =
| 'school_id'
| 'firstname'
@@ -4222,7 +4225,7 @@ export function AdminStudentProfilesPage() {
| 'is_active'
| 'photo_consent'
| 'registration_date'
const [schoolYear, setSchoolYear] = useState('')
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [students, setStudents] = useState<StudentDirectoryRow[]>([])
const [editing, setEditing] = useState<StudentDirectoryRow | null>(null)
const [parentDetails, setParentDetails] = useState<Record<string, unknown> | null>(null)
@@ -4345,10 +4348,10 @@ export function AdminStudentProfilesPage() {
<AdminParityShell title="Student Profiles">
{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 gap-2">
<input className="form-control" style={{ maxWidth: 240 }} value={schoolYear} onChange={(e) => setSchoolYear(e.target.value)} placeholder="School year" />
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()}>Apply</button>
</div>
<CiAcademicFilter
selectedYear={schoolYear}
onApply={({ schoolYear: year }) => void load(year)}
/>
<div className="input-group" style={{ maxWidth: 360 }}>
<input
className="form-control"
@@ -4452,9 +4455,11 @@ export function AdminStudentProfilesPage() {
}
export function AdminSubjectCurriculumPage() {
const [searchParams] = useSearchParams()
const [classes, setClasses] = useState<Array<{ id: number; class_name: string }>>([])
const [entries, setEntries] = useState<SubjectCurriculumEntryRow[]>([])
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 [editingId, setEditingId] = useState<number | null>(null)
const [message, setMessage] = useState<string | null>(null)
@@ -4462,14 +4467,15 @@ export function AdminSubjectCurriculumPage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
async function load() {
async function load(year = schoolYear) {
setLoading(true)
setError(null)
try {
const data = await fetchSubjectCurriculum()
const data = await fetchSubjectCurriculum(year || undefined)
setClasses(data.classes ?? [])
setEntries(data.entries ?? [])
setSubjects(data.subject_labels ?? {})
setSchoolYear(data.school_year ?? year ?? '')
} catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load curriculum.')
} finally {
@@ -4491,6 +4497,7 @@ export function AdminSubjectCurriculumPage() {
unit_number: form.unit_number ? Number(form.unit_number) : null,
unit_title: form.unit_title || null,
chapter_name: form.chapter_name,
school_year: schoolYear || undefined,
}
try {
if (editingId) {
@@ -4528,6 +4535,10 @@ export function AdminSubjectCurriculumPage() {
<AdminParityShell title="Subject Curriculum">
{message ? <div className="alert alert-info">{message}</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="col-lg-5">
<div className="card shadow-sm">
@@ -4583,17 +4594,17 @@ export function AdminSubjectCurriculumPage() {
<div className="card-header">Current curriculum entries</div>
<div className="table-responsive">
<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>
{loading ? (
<tr>
<td colSpan={6} className="text-muted">
<td colSpan={7} className="text-muted">
Loading
</td>
</tr>
) : entries.length === 0 ? (
<tr>
<td colSpan={6} className="text-muted">
<td colSpan={7} className="text-muted">
No curriculum records yet.
</td>
</tr>
@@ -4601,6 +4612,7 @@ export function AdminSubjectCurriculumPage() {
entries.map((entry) => (
<tr key={entry.id}>
<td>{entry.class_name ?? '—'}</td>
<td>{entry.school_year ?? schoolYear ?? '—'}</td>
<td>{subjects[entry.subject] ?? entry.subject}</td>
<td>{entry.unit_number ?? '—'}</td>
<td>{entry.unit_title ?? '—'}</td>
@@ -4646,7 +4658,8 @@ export function AdminSubjectCurriculumPage() {
}
export function AdminTeacherClassAssignmentPage() {
const [schoolYear, setSchoolYear] = useState('')
const [searchParams] = useSearchParams()
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [teachers, setTeachers] = useState<TeacherClassAssignmentRow[]>([])
const [classes, setClasses] = useState<TeacherClassOption[]>([])
const [selectedTeacherId, setSelectedTeacherId] = useState<number | null>(null)
@@ -4696,7 +4709,7 @@ export function AdminTeacherClassAssignmentPage() {
<AdminParityShell title="Teacher Class Assignments">
{message ? <div className="alert alert-info">{message}</div> : null}
<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-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>
@@ -4742,7 +4755,7 @@ export function AdminTeacherClassAssignmentPage() {
}
export function AdminTeacherSubmissionsPage() {
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const [rows, setRows] = useState<TeacherSubmissionReportRow[]>([])
const [summary, setSummary] = useState<{ total_items?: number; missing_items?: number; submitted_items?: number; submission_percentage?: number }>({})
const [selected, setSelected] = useState<Record<string, boolean>>({})
@@ -4794,6 +4807,18 @@ export function AdminTeacherSubmissionsPage() {
<AdminParityShell title="Teacher Submission Dashboard">
{message ? <div className="alert alert-info">{message}</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="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>
+5 -5
View File
@@ -12,8 +12,8 @@ export function LoginPage() {
const locState = location.state as { from?: string; registered?: boolean; message?: string } | null
const from = locState?.from ?? '/app/home'
const flashSuccess =
locState?.registered === true ? locState?.message ?? 'Registration submitted.' : null
const flashMessage = locState?.message ?? null
const flashVariant = locState?.registered === true ? 'success' : 'warning'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -101,9 +101,9 @@ export function LoginPage() {
Login to Your Account
</h3>
{flashSuccess ? (
<div className="alert alert-success text-center" role="alert">
{flashSuccess}
{flashMessage ? (
<div className={`alert alert-${flashVariant} text-center`} role="alert">
{flashMessage}
</div>
) : null}
+72 -33
View File
@@ -20,6 +20,59 @@ type NavFormState = {
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 = {
id: '',
menu_parent_id: '',
@@ -47,7 +100,7 @@ export function NavBuilderPage() {
setLoading(true)
try {
const res = await fetchNavBuilderData()
setPayload(res.data?.data ?? { items: [], roles: [], parentOptions: [] })
setPayload(normalizeBuilderData(res.data?.data ?? { items: [], roles: [], parentOptions: [] }))
setMessages([])
} catch (error) {
setMessages([{ type: 'warning', message: error instanceof Error ? error.message : 'Failed to load menu items.' }])
@@ -60,8 +113,10 @@ export function NavBuilderPage() {
void load()
}, [])
const sortedItems = useMemo(() => sortBuilderItems(payload.items), [payload.items])
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],
)
@@ -77,7 +132,7 @@ export function NavBuilderPage() {
url: form.url.trim() || null,
icon_class: form.icon_class.trim() || null,
target: form.target.trim() || null,
sort_order: Number(form.sort_order) || 0,
sort_order: 0,
is_enabled: form.is_enabled,
roles: form.roles.map(Number).filter((id) => id > 0),
})
@@ -105,13 +160,11 @@ export function NavBuilderPage() {
}
async function onSaveOrder() {
const orders = Object.fromEntries(
payload.items.map((item) => [String(item.id), Number(item.sort_order ?? item.order ?? 0)]),
)
const orders = alphabeticalOrdersByParent(payload.items)
setMessages([])
try {
await reorderNavBuilderItems(orders)
setMessages([{ type: 'success', message: 'Menu order saved.' }])
setMessages([{ type: 'success', message: 'Alphabetical menu order saved.' }])
await load()
} catch (error) {
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' })
}
const alphabeticalOrders = useMemo(() => alphabeticalOrdersByParent(sortedItems), [sortedItems])
const editingItem = form.id
? payload.items.find((item) => String(item.id) === form.id) ?? null
: null
@@ -154,7 +209,7 @@ export function NavBuilderPage() {
<div className="card-header d-flex justify-content-between align-items-center">
<span>Current Menu</span>
<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void onSaveOrder()}>
Save order
Save alphabetical order
</button>
</div>
<div className="card-body">
@@ -167,7 +222,7 @@ export function NavBuilderPage() {
<th>URL</th>
<th>Parent</th>
<th>Roles</th>
<th>Order</th>
<th>Alphabetical position</th>
<th>Enabled</th>
<th>Depth</th>
</tr>
@@ -182,7 +237,7 @@ export function NavBuilderPage() {
<td colSpan={8} className="text-center text-muted">No menu items found.</td>
</tr>
) : (
payload.items.map((item) => (
sortedItems.map((item) => (
<tr key={item.id}>
<td className="text-nowrap">
<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}
</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>
{(item.roles?.names ?? []).length > 0 ? (
item.roles?.names?.map((roleName) => (
@@ -211,21 +266,12 @@ export function NavBuilderPage() {
)}
</td>
<td>
<input
className="form-control form-control-sm nav-builder-order"
type="number"
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)),
}))
}}
/>
<span className="badge bg-light text-dark">
{alphabeticalOrders[String(item.id)] ?? '-'}
</span>
</td>
<td>
{Boolean(item.is_enabled ?? item.enabled) ? (
{(item.is_enabled ?? item.enabled) ? (
<span className="badge bg-success">Yes</span>
) : (
<span className="badge bg-light text-dark">No</span>
@@ -312,15 +358,8 @@ export function NavBuilderPage() {
</select>
</div>
<div className="col-md-4 mb-2">
<label className="form-label" htmlFor="sort_order">Order</label>
<input
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 }))}
/>
<label className="form-label">Order</label>
<div className="form-control bg-light text-muted">Alphabetical</div>
</div>
<div className="col-md-4 mb-2 d-flex align-items-end">
<div className="form-check">
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchAdministratorAdminAttendance,
saveAdministratorAdminAttendance,
@@ -26,6 +27,9 @@ export function AdminsAttendanceFormPage() {
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
@@ -143,13 +147,13 @@ export function AdminsAttendanceFormPage() {
>
<div className="col-sm-3">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" 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 className="col-sm-2">
<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 { Link, useSearchParams } from 'react-router-dom'
import {
ATTENDANCE_VIOLATIONS_NOTIFIED_PATH,
ATTENDANCE_VIOLATIONS_PENDING_PATH,
} from './attendancePaths'
import { Navigate, useSearchParams } from 'react-router-dom'
import { 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() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const [searchParams] = useSearchParams()
const query = searchParams.toString()
const to = query ? `${ATTENDANCE_VIOLATIONS_PENDING_PATH}?${query}` : ATTENDANCE_VIOLATIONS_PENDING_PATH
const filterQuery = useMemo(() => {
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>
)
return <Navigate to={to} replace />
}
@@ -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 { Link, useSearchParams } from 'react-router-dom'
import { fetchEarlyDismissals, uploadEarlyDismissalSignature } from '../../api/session'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { apiUrl } from '../../lib/apiOrigin'
import { EARLY_DISMISSALS_NEW_PATH, EARLY_DISMISSALS_PATH } from './attendancePaths'
@@ -20,6 +21,9 @@ export function EarlyDismissalsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [flash, setFlash] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
@@ -90,13 +94,13 @@ export function EarlyDismissalsPage() {
>
<div className="col-md-4">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" 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 className="col-md-3">
<label className="form-label">Semester</label>
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchParentAttendanceReportsAdmin } from '../../api/session'
import type { ParentAttendanceAdminReportRow } from '../../api/types'
@@ -14,6 +15,9 @@ export function ParentAttendanceReportsAdminPage() {
const [rows, setRows] = useState<ParentAttendanceAdminReportRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
@@ -63,13 +67,13 @@ export function ParentAttendanceReportsAdminPage() {
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={apply}>
<div className="col-md-3">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" 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 className="col-md-2">
<label className="form-label">Semester</label>
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session'
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
import type { TeacherStaffAttendanceRow } from '../../api/types'
@@ -26,6 +27,9 @@ export function TeacherAttendanceFormPage() {
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
@@ -136,13 +140,13 @@ export function TeacherAttendanceFormPage() {
<form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}>
<div className="col-sm-3">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" 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 className="col-sm-2">
<label className="form-label">Semester</label>
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { apiUrl } from '../../lib/apiOrigin'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchStaffMonthlyAttendanceOverview,
saveStaffMonthlyAttendanceCell,
@@ -66,7 +67,15 @@ export function TeacherAttendanceMonthPage() {
semester: semesterParam === '---' ? null : semesterParam,
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) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load monthly attendance.')
} finally {
@@ -96,6 +105,10 @@ export function TeacherAttendanceMonthPage() {
if (ys.length === 0 && schoolYearParam) ys.push(schoolYearParam)
return ys
}, [payload?.schoolYears, schoolYearParam])
const { options } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYearParam,
})
const hasTeacherSections = (payload?.sections?.length ?? 0) > 0
const hasAdmins = (payload?.admins?.length ?? 0) > 0
@@ -194,9 +207,9 @@ export function TeacherAttendanceMonthPage() {
<div className="col-md-3">
<label className="form-label">School Year</label>
<select name="school_year" className="form-select" defaultValue={schoolYearParam}>
{schoolYears.map((y) => (
<option key={y} value={y}>
{y}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+31 -2
View File
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchAttendanceViolationsNotified,
fetchAttendanceViolationsPending,
@@ -29,6 +30,9 @@ export function ViolationsPendingPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
const filterQuery = useMemo(() => {
const s = searchParams.toString()
@@ -94,7 +98,18 @@ export function ViolationsPendingPage() {
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto">
<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 className="col-auto">
<label htmlFor="pending-semester" className="form-label small mb-0">Semester</label>
@@ -315,6 +330,9 @@ export function ViolationsNotifiedPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
const filterQuery = useMemo(() => {
const s = searchParams.toString()
@@ -399,7 +417,18 @@ export function ViolationsNotifiedPage() {
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto">
<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 className="col-auto">
<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 { ViolationsNotifiedPage, ViolationsPendingPage } from './ViolationsPages'
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 { useSearchParams } from 'react-router-dom'
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchCertFormOptions,
fetchCertificateReprint,
fetchCertificatesDashboard,
postCertificateGenerate,
type CertClassSection,
type CertStudent,
type CertificateDashboardPayload,
type CertificateSectionRow,
type CertificateStudentRow,
} from '../../api/certificates'
export function CertificatesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const classId = searchParams.get('class_section_id') ?? ''
const schoolYear = searchParams.get('school_year') ?? ''
function formatScore(value: number | null) {
return value == null ? '—' : value.toFixed(2)
}
const [classSections, setClassSections] = useState<CertClassSection[]>([])
const [students, setStudents] = useState<CertStudent[]>([])
const [currentYear, setCurrentYear] = useState('')
const [loadingStudents, setLoadingStudents] = useState(false)
const [error, setError] = useState<string | null>(null)
function isoToday() {
return new Date().toISOString().slice(0, 10)
}
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [certDate, setCertDate] = useState(() => {
const d = new Date()
return d.toISOString().slice(0, 10)
})
const [generating, setGenerating] = useState(false)
function toDisplayDate(isoDate: string) {
const date = new Date(`${isoDate}T00:00:00`)
if (Number.isNaN(date.getTime())) return isoDate
return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${date.getFullYear()}`
}
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(() => {
fetchCertFormOptions({ school_year: schoolYear || undefined })
.then((raw: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = (raw as any)?.data ?? raw
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])
setSelectedIds([])
setCertDate(isoToday())
setError(null)
}, [section.section_id, defaultCertDate])
// Load students when class changes
useEffect(() => {
if (!classId) {
setStudents([])
setSelectedIds(new Set())
const allSelected = eligibleStudents.length > 0 && selectedIds.length === eligibleStudents.length
const partiallySelected = selectedIds.length > 0 && !allSelected
async function handleGenerate(event: FormEvent) {
event.preventDefault()
if (selectedIds.length === 0) {
setError('Please select at least one student.')
return
}
setLoadingStudents(true)
setBusy(true)
setError(null)
fetchCertFormOptions({ class_section_id: classId, school_year: schoolYear || undefined })
.then((raw: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = (raw as any)?.data ?? raw
setStudents(Array.isArray(payload?.students) ? payload.students : [])
setSelectedIds(new Set())
try {
const blob = await postCertificateGenerate({
student_ids: selectedIds,
cert_date: toDisplayDate(certDate),
class_section_id: section.section_id,
school_year: schoolYear,
})
.catch(() => setStudents([]))
.finally(() => setLoadingStudents(false))
}, [classId, schoolYear])
// Keep select-all checkbox tri-state in sync
useEffect(() => {
const el = selectAllRef.current
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
downloadBlob(blob)
await onGenerated()
setSelectedIds([])
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to generate certificates.')
} finally {
setBusy(false)
}
}, [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) {
const next = new URLSearchParams(searchParams)
if (value) next.set('school_year', value)
else next.delete('school_year')
next.delete('class_section_id')
setSearchParams(next, { replace: true })
async function handleReprint(certificateNumber: string) {
setBusy(true)
setError(null)
try {
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) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
function toggleStudent(studentId: number) {
setSelectedIds((previous) =>
previous.includes(studentId)
? previous.filter((id) => id !== studentId)
: [...previous, studentId],
)
}
function toggleAll(checked: boolean) {
if (checked) setSelectedIds(new Set(students.map((s) => s.id)))
else setSelectedIds(new Set())
setSelectedIds(checked ? eligibleStudents.map((student) => student.student_id) : [])
}
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 (
<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-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>
<form className="mb-5" onSubmit={handleGenerate}>
<h4 className="mt-4 mb-2 text-center">{section.section_name}</h4>
{error && (
<div className="alert alert-danger alert-dismissible fade show" role="alert">
{error}
<button
type="button"
className="btn-close"
onClick={() => setError(null)}
aria-label="Close"
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div className="d-flex align-items-center gap-4 flex-wrap">
<span className="fw-semibold">
Students <span className="badge bg-secondary ms-1">{section.student_count}</span>
</span>
<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>
)}
{/* 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>
{loadingStudents && (
<div className="text-muted">Loading students</div>
)}
{!loadingStudents && hasStudents && (
<form onSubmit={handleGenerate}>
<div className="card shadow-sm mb-3">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span className="fw-semibold">
Students
<span className="badge bg-secondary ms-1">{students.length}</span>
</span>
<div className="d-flex align-items-center gap-3">
<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={(e) => setCertDate(e.target.value)}
title="Certificate date"
/>
</div>
<div className="form-check mb-0">
<div className="table-responsive">
<table className="table table-hover table-striped align-middle mb-0">
<thead className="table-light">
<tr>
<th style={{ width: 40 }} className="text-center">
<input
className="form-check-input"
type="checkbox"
checked={allSelected}
ref={(node) => {
if (node) node.indeterminate = partiallySelected
}}
onChange={(event) => toggleAll(event.target.checked)}
/>
</th>
<th>First Name</th>
<th>Last Name</th>
<th className="text-center">Year Score</th>
<th>Decision</th>
<th>Certificate No.</th>
</tr>
</thead>
<tbody>
{section.students.map((student) => (
<tr key={student.student_id}>
<td className="text-center">
<input
className="form-check-input"
type="checkbox"
id="selectAll"
ref={selectAllRef}
onChange={(e) => toggleAll(e.target.checked)}
checked={selectedIds.includes(student.student_id)}
disabled={!student.eligible || busy}
onChange={() => toggleStudent(student.student_id)}
/>
<label className="form-check-label" htmlFor="selectAll">
Select all
</label>
</div>
</div>
</div>
</td>
<td>{student.firstname}</td>
<td>{student.lastname}</td>
<td className="text-center fw-semibold">{formatScore(student.year_score)}</td>
<td>
<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="table-responsive">
<table className="table table-hover table-striped align-middle mb-0">
<thead className="table-light">
<tr>
<th style={{ width: 40 }} />
<th>Last Name</th>
<th>First Name</th>
<th>Grade / Class</th>
</tr>
</thead>
<tbody>
{students.map((s) => (
<tr key={s.id}>
<td className="text-center">
<input
className="form-check-input"
type="checkbox"
checked={selectedIds.has(s.id)}
onChange={() => toggleStudent(s.id)}
/>
</td>
<td>{s.lastname}</td>
<td>{s.firstname}</td>
<td>{s.grade}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="d-flex justify-content-between align-items-center mt-2">
<span className="text-muted small">
{selectedIds.length} student{selectedIds.length !== 1 ? 's' : ''} selected
</span>
<button type="submit" className="btn btn-success" disabled={selectedIds.length === 0 || busy}>
{busy ? (
<>
<span className="spinner-border spinner-border-sm me-1" />
Generating...
</>
) : (
<>
<i className="bi bi-printer me-1" />
Generate & Print Certificates
</>
)}
</button>
</div>
</form>
)
}
<div className="card-footer d-flex justify-content-between align-items-center">
<span className="text-muted small">
{selectedIds.size} student{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<button
type="submit"
className="btn btn-success"
disabled={selectedIds.size === 0 || generating}
>
{generating ? (
<>
<span className="spinner-border spinner-border-sm me-1" role="status" />
Generating
</>
) : (
<>
<i className="bi bi-printer me-1" />
Generate &amp; Print Certificates
</>
)}
</button>
export function CertificatesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const groupKey = searchParams.get('group') ?? ''
const [data, setData] = useState<CertificateDashboardPayload | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [yearInput, setYearInput] = useState('')
async function loadDashboard(currentSchoolYear: string) {
setLoading(true)
setError(null)
try {
const payload = await fetchCertificatesDashboard({
school_year: currentSchoolYear || undefined,
})
setData(payload)
setYearInput(payload.school_year || currentSchoolYear)
} 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>
</form>
)}
{!loadingStudents && classId && !hasStudents && (
<div className="alert alert-warning">
No active students found for the selected class and school year.
<Link className="btn btn-outline-secondary btn-sm" to="/app/administrator/certificates/log">
<i className="bi bi-journal-check me-1" />
Audit Log
</Link>
</div>
)}
{!loadingStudents && !classId && (
<div className="alert alert-info">
Select a class above to load its student roster.
{error ? <div className="alert alert-danger">{error}</div> : null}
<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>
)}
{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>
)
}
+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 { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchClassPrepIndex, saveClassPrepAdjustment } from '../../api/session'
import type { ClassPrepIndexResponse, ClassPrepSectionRow } from '../../api/types'
@@ -9,14 +10,9 @@ function adjustQty(input: HTMLInputElement, delta: number) {
input.value = String(curr + delta)
}
function defaultSchoolYear() {
const y = new Date().getFullYear()
return `${y}-${y + 1}`
}
export function ClassPrepListPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? defaultSchoolYear()
const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const [data, setData] = useState<ClassPrepIndexResponse | null>(null)
@@ -24,19 +20,16 @@ export function ClassPrepListPage() {
const [error, setError] = useState<string | null>(null)
const [savingId, setSavingId] = useState<string | null>(null)
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const yearOptions = useMemo(() => {
const out: string[] = []
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
return out
}, [])
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetchClassPrepIndex({
school_year: schoolYear,
school_year: schoolYear || selectedYear,
semester: semester || undefined,
})
.then((d) => {
@@ -115,11 +108,11 @@ export function ClassPrepListPage() {
name="school_year"
id="school_year"
className="form-select form-select-sm w-auto"
defaultValue={schoolYear}
defaultValue={schoolYear || selectedYear}
>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
@@ -1,47 +1,255 @@
import { useParams } from 'react-router-dom'
import { ApiScopeNote, CompetitionPageShell } from './competitionWinnersShared'
import { useEffect, useMemo, useState } from 'react'
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() {
const { id } = useParams()
const navigate = useNavigate()
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 (
<CompetitionPageShell title={isEdit ? 'Edit Competition' : 'Create Competition'}>
<ApiScopeNote>
Saving competitions and per-class prize grids requires POST endpoints that mirror CI (
<code>store</code>, <code>update</code>). Connect the API to enable the fields below.
This screen now saves directly through the Laravel admin competition endpoints, including per-class winner overrides
and prize grids.
</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="col-md-8">
<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 className="col-md-6">
<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>
{(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>
<div className="form-text">Leave empty to manage winners for all classes.</div>
</div>
<div className="col-md-3">
<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 className="col-md-3">
<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>
<div className="col-md-4">
<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 className="col-md-4">
<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 className="col-12">
@@ -65,23 +273,91 @@ export function CompetitionWinnerFormPage() {
</tr>
</thead>
<tbody>
<tr>
<td colSpan={12} className="text-muted small">
Class rows load from the backend when competition settings APIs are available.
</td>
</tr>
{visibleRows.length === 0 ? (
<tr>
<td colSpan={12} className="text-muted small">
No class rows found for the selected scope.
</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>
</table>
</div>
</div>
<div className="col-12">
<button type="button" className="btn btn-primary" disabled>
Save
<div className="col-12 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => navigate(COMPETITION_WINNERS_BASE)}
disabled={saving}
>
Back
</button>
</div>
</div>
</div>
</form>
</CompetitionPageShell>
)
}
@@ -1,85 +1,101 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { fetchPublishedCompetition } from '../../api/session'
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types'
import { fetchAdminCompetitionWinnerPreview, publishAdminCompetitionWinner } from '../../api/session'
import type { CompetitionWinnerAdminPreviewResponse, CompetitionWinnerPreviewStudentRow } from '../../api/types'
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` */
export function CompetitionWinnerPreviewPage() {
const { id } = useParams()
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 (
<CompetitionPageShell title="Preview Winners">
<ApiScopeNote>
Preview reflects published winners from the public API. Unpublished drafts are not shown until the backend exposes an
admin preview.
Preview is generated from the admin scoring data, so it works before a competition is publicly published.
</ApiScopeNote>
{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>
<strong>Competition:</strong> {competitionLabel(competition)}
</div>
<div>
<strong>School Year:</strong> {competition.school_year ?? '—'}
<div>
<strong>Competition:</strong> {competitionLabel(data.competition)}
</div>
<div>
<strong>School Year:</strong> {data.competition.school_year ?? '—'}
</div>
<div>
<strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
</div>
</div>
<button type="button" className="btn btn-primary" disabled={publishing} onClick={() => void handlePublish()}>
{publishing ? 'Publishing...' : 'Publish Winners'}
</button>
</div>
{Object.keys(winnersByClass).length === 0 ? (
<div className="alert alert-warning">No published winners found for this competition.</div>
{Object.keys(data.topByClass ?? {}).length === 0 ? (
<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">
<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">
<thead className="table-light">
<tr>
<th>Rank</th>
<th>Student</th>
<th>Score</th>
<th>Question Count</th>
<th>Prize</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
{(rows as CompetitionWinnerPreviewStudentRow[]).map((row, index) => (
<tr key={`${classId}-${index}`}>
<td>{row.rank ?? index + 1}</td>
<td>
{row.name ??
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
`Student #${row.student_id ?? ''}`)}
</td>
<td>{row.name ?? `Student #${row.student_id ?? ''}`}</td>
<td>{row.score ?? '—'}</td>
<td>{data.questionCounts?.[classId] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td>
</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 {
fetchCompetitionScoresDetail,
saveCompetitionScores,
fetchAdminCompetitionWinnerScores,
saveAdminCompetitionWinnerScores,
} from '../../api/session'
import type { CompetitionScoresEditResponse, CompetitionScoreStudentRow } from '../../api/types'
import type { CompetitionScoreStudentRow, CompetitionWinnerAdminScoresResponse } from '../../api/types'
import {
ApiScopeNote,
CompetitionPageShell,
@@ -12,20 +12,26 @@ import {
competitionLabel,
} 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() {
const { id } = useParams()
const competitionId = Number(id)
const [searchParams, setSearchParams] = useSearchParams()
const selectedClassId = Number(searchParams.get('class_section_id') ?? 0) || null
const [data, setData] = useState<CompetitionScoresEditResponse | null>(null)
const selectedClassId = numberOrNull(searchParams.get('class_section_id'))
const [data, setData] = useState<CompetitionWinnerAdminScoresResponse | null>(null)
const [scores, setScores] = useState<Record<string, number | string>>({})
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function load(classSectionId = selectedClassId) {
try {
const response = await fetchCompetitionScoresDetail(competitionId, classSectionId)
const response = await fetchAdminCompetitionWinnerScores(competitionId, classSectionId)
setData(response)
setScores(response.scoreMap ?? {})
setError(response.ok ? null : response.message ?? 'Unable to load competition scores.')
@@ -39,12 +45,23 @@ export function CompetitionWinnerScoresPage() {
void load()
}, [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) {
e.preventDefault()
if (!competitionId) return
try {
const result = await saveCompetitionScores(competitionId, {
class_section_id: data?.classSectionId ?? selectedClassId,
const result = await saveAdminCompetitionWinnerScores(competitionId, {
class_section_id: selectedClassId ?? data?.activeClassSectionId ?? null,
scores,
})
setMessage(result.message ?? 'Scores saved.')
@@ -59,7 +76,8 @@ export function CompetitionWinnerScoresPage() {
return (
<CompetitionPageShell title="Enter Scores">
<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>
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
@@ -74,18 +92,27 @@ export function CompetitionWinnerScoresPage() {
</div>
<div>
<strong>Students:</strong> {data.classStudentCount ?? 0} | <strong>Questions:</strong>{' '}
{data.questionCount ?? '—'}
{data.classQuestionCount ?? '—'} | <strong>Final Winners:</strong> {data.classFinalWinners ?? '—'}
</div>
</div>
<form onSubmit={(e) => void onSubmit(e)}>
{data.classSelectionLocked === false ? (
<div className="mb-3" style={{ maxWidth: 360 }}>
<label className="form-label">Class Section ID</label>
<input
className="form-control"
value={String(selectedClassId ?? data.classSectionId ?? '')}
onChange={(e) => setSearchParams(e.target.value ? { class_section_id: e.target.value } : {})}
/>
<label className="form-label">Class Section</label>
<select
className="form-select"
value={String(selectedClassId ?? data.activeClassSectionId ?? '')}
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>
) : null}
<div className="table-responsive">
@@ -94,7 +121,7 @@ export function CompetitionWinnerScoresPage() {
<tr>
<th>School ID</th>
<th>Student</th>
<th>Score{data.questionCount ? ` (max ${data.questionCount})` : ''}</th>
<th>Score{data.classQuestionCount ? ` (max ${data.classQuestionCount})` : ''}</th>
</tr>
</thead>
<tbody>
@@ -120,8 +147,8 @@ export function CompetitionWinnerScoresPage() {
type="number"
step="1"
min="0"
max={data.questionCount ?? undefined}
disabled={Boolean(data.isLocked)}
max={data.classQuestionCount ?? undefined}
disabled={Boolean(data.competition?.is_locked)}
value={String(scores[String(studentId)] ?? '')}
onChange={(e) =>
setScores((current) => ({
@@ -139,7 +166,7 @@ export function CompetitionWinnerScoresPage() {
</table>
</div>
<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
</button>
<Link className="btn btn-outline-secondary" to={`${COMPETITION_WINNERS_BASE}/${competitionId}/preview`}>
@@ -1,77 +1,54 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { fetchPublishedCompetition } from '../../api/session'
import type { PublicCompetitionRow, PublicCompetitionWinnerRow } from '../../api/types'
import { fetchAdminCompetitionWinnerWinners } from '../../api/session'
import type { CompetitionWinnerAdminWinnerRow, CompetitionWinnerAdminWinnersResponse } from '../../api/types'
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` */
export function CompetitionWinnerWinnersPage() {
const { id } = useParams()
const competitionId = Number(id)
const { competition, winnersByClass, sectionMap, questionCounts, error } =
usePublishedCompetitionFull(competitionId)
const [data, setData] = useState<CompetitionWinnerAdminWinnersResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const flatRows = useMemo(
() =>
Object.entries(winnersByClass).flatMap(([classId, rows]) =>
rows.map((row) => ({ ...row, _classId: classId })),
),
[winnersByClass],
useEffect(() => {
if (!Number.isFinite(competitionId) || competitionId <= 0) return
;(async () => {
try {
const response = await fetchAdminCompetitionWinnerWinners(competitionId)
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 (
<CompetitionPageShell title="School Winners">
<ApiScopeNote>
Prize totals and rankings come from the published winners API. Export to recognition PDF requires an admin endpoint if
not yet in Laravel.
This listing comes from the admin winners table and reflects the last published winner set for the competition.
</ApiScopeNote>
{error ? <div className="alert alert-danger">{error}</div> : null}
{competition ? (
{data?.competition ? (
<>
<div className="mb-3">
<div>
<strong>Competition:</strong> {competitionLabel(competition)}
<strong>Competition:</strong> {competitionLabel(data.competition)}
</div>
<div>
<strong>School Year:</strong> {competition.school_year ?? '—'}
<strong>School Year:</strong> {data.competition.school_year ?? '—'}
</div>
<div>
<strong>Published:</strong> {competition.is_published ? 'Yes' : 'No'}
<strong>Published:</strong> {data.competition.is_published ? 'Yes' : 'No'}
</div>
</div>
{flatRows.length === 0 ? (
{(data.rows ?? []).length === 0 ? (
<div className="alert alert-warning">No published winners yet.</div>
) : (
<div className="table-responsive">
@@ -80,24 +57,24 @@ export function CompetitionWinnerWinnersPage() {
<tr>
<th>Class</th>
<th>Student</th>
<th>School ID</th>
<th>Rank</th>
<th>Score</th>
<th>Question Count</th>
<th>Prize</th>
</tr>
</thead>
<tbody>
{flatRows.map((row, index) => (
<tr key={`${row._classId}-${index}`}>
<td>{sectionMap[String(row._classId)] ?? `Class ${row._classId}`}</td>
{(data.rows ?? []).map((row: CompetitionWinnerAdminWinnerRow, index) => (
<tr key={`${row.class_section_id ?? 'class'}-${row.student_id ?? index}-${index}`}>
<td>{data.sectionMap?.[String(row.class_section_id ?? '')] ?? `Class ${row.class_section_id ?? ''}`}</td>
<td>
{row.name ??
(`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() ||
`Student #${row.student_id ?? ''}`)}
</td>
<td>{row.school_id ?? '—'}</td>
<td>{row.rank ?? '—'}</td>
<td>{row.score ?? '—'}</td>
<td>{questionCounts[String(row._classId)] ?? '—'}</td>
<td>{row.prize_amount ?? '—'}</td>
</tr>
))}
@@ -1,62 +1,109 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchCompetitionScoresIndex, fetchPublishedCompetitions } from '../../api/session'
import type { CompetitionScoresIndexCompetitionRow, PublicCompetitionRow } from '../../api/types'
import {
exportAdminCompetitionWinnerQuiz,
fetchAdminCompetitionWinnersIndex,
lockAdminCompetitionWinner,
unlockAdminCompetitionWinner,
} from '../../api/session'
import type { CompetitionWinnerAdminCompetitionRow } from '../../api/types'
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() {
const [publishedRows, setPublishedRows] = useState<PublicCompetitionRow[]>([])
const [adminRows, setAdminRows] = useState<CompetitionScoresIndexCompetitionRow[]>([])
const [rows, setRows] = useState<CompetitionWinnerAdminCompetitionRow[]>([])
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 [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(() => {
;(async () => {
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.')
}
})()
void load()
}, [])
const tableRows = useMemo(() => {
if (adminRows.length > 0) return adminRows
return publishedRows.map((r) => ({
id: r.id,
title: r.title,
class_section_id: r.class_section_id,
is_locked: r.is_locked,
is_published: r.is_published,
})) as CompetitionScoresIndexCompetitionRow[]
}, [adminRows, publishedRows])
async function handleExport(row: CompetitionWinnerAdminCompetitionRow) {
const id = competitionId(row)
if (id === null) {
setError('This competition does not have a valid id yet.')
return
}
try {
setBusyAction(`export:${id}`)
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 (
<CompetitionPageShell title="Competition Winners">
<ApiScopeNote>
Lock/unlock, export quiz, and competition CRUD match CI once Laravel exposes those admin routes. Score entry and
published winners work with the current API.
Admin competition management now uses dedicated Laravel endpoints. Draft competitions keep working here, and preview
is no longer limited to published rows.
</ApiScopeNote>
{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 />
@@ -79,63 +126,75 @@ export function CompetitionWinnersIndexPage() {
</tr>
</thead>
<tbody>
{tableRows.length === 0 ? (
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="text-muted">
No competitions found.
</td>
</tr>
) : (
tableRows.map((c) => {
const sectionId = Number(c.class_section_id ?? 0)
rows.map((row) => {
const id = competitionId(row)
const sectionId = toFiniteNumber(row.class_section_id) ?? 0
const sectionName =
sectionId > 0 ? (sectionMap[String(sectionId)] ?? String(sectionId)) : 'All'
const isLocked = Boolean(c.is_locked)
const isPublished = Boolean(c.is_published)
const id = Number(c.id)
const title = String(row.title ?? (id !== null ? `Competition #${id}` : 'Competition'))
const exportBusy = busyAction === `export:${id}`
const lockBusy = busyAction === `lock:${id}`
return (
<tr key={id}>
<td>{id}</td>
<td>{String(c.title ?? `Competition #${id}`)}</td>
<tr key={id !== null ? String(id) : `${title}-${sectionId}`}>
<td>{id ?? '—'}</td>
<td>{title}</td>
<td>{sectionName}</td>
<td>Tiered per class</td>
<td>{isPublished ? 'Yes' : 'No'}</td>
<td>{isLocked ? 'Yes' : 'No'}</td>
<td>{row.is_published ? 'Yes' : 'No'}</td>
<td>{row.is_locked ? 'Yes' : 'No'}</td>
<td>
<div className="d-flex flex-wrap gap-1">
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
>
Settings
</Link>
<Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}>
Enter Scores
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/preview`}
>
Preview
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/winners`}
>
Winners
</Link>
<button type="button" className="btn btn-sm btn-outline-success" disabled title="Requires API">
Export Scores to Quiz
</button>
{isLocked ? (
<button type="button" className="btn btn-sm btn-outline-warning" disabled title="Requires API">
Unlock
</button>
{id !== null ? (
<>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
>
Settings
</Link>
<Link className="btn btn-sm btn-outline-primary" to={`${COMPETITION_WINNERS_BASE}/${id}`}>
Enter Scores
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/preview`}
>
Preview
</Link>
<Link
className="btn btn-sm btn-outline-secondary"
to={`${COMPETITION_WINNERS_BASE}/${id}/winners`}
>
Winners
</Link>
</>
) : (
<button type="button" className="btn btn-sm btn-outline-danger" disabled title="Requires API">
Lock
</button>
<span className="text-muted small me-2">Invalid competition id</span>
)}
<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>
</td>
</tr>
@@ -145,10 +204,6 @@ export function CompetitionWinnersIndexPage() {
</tbody>
</table>
</div>
{activeClassName ? (
<p className="text-muted small mt-3 mb-0">Active class context: {activeClassName}</p>
) : null}
</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'
function defaultSchoolYear() {
const y = new Date().getFullYear()
return `${y}-${y + 1}`
}
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
const [searchParams] = useSearchParams()
@@ -12,24 +8,26 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
const { pathname } = useLocation()
const schoolYear = searchParams.get('school_year') ?? ''
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 =
schoolYears && schoolYears.length > 0
? schoolYears
: (() => {
const out: string[] = []
for (let y = new Date().getFullYear(); y >= 2020; y--) out.push(`${y}-${y + 1}`)
return out
})()
useEffect(() => {
setYearValue(schoolYear || selectedYear)
}, [schoolYear, selectedYear])
useEffect(() => {
setSemesterValue(semester)
}, [semester])
function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const next = new URLSearchParams()
const sy = String(fd.get('school_year') ?? '')
const sem = String(fd.get('semester') ?? '')
if (sy) next.set('school_year', sy)
if (sem) next.set('semester', sem)
if (yearValue) next.set('school_year', yearValue)
if (semesterValue) next.set('semester', semesterValue)
navigate({ pathname, search: next.toString() ? `?${next}` : '' })
}
@@ -46,11 +44,12 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
name="school_year"
className="form-select form-select-sm"
style={{ minWidth: 180 }}
defaultValue={schoolYear || years[0] || defaultSchoolYear()}
value={yearValue}
onChange={(event) => setYearValue(event.target.value)}
>
{years.map((y) => (
<option key={y} value={y}>
{y}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
@@ -63,7 +62,8 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
name="semester"
className="form-select form-select-sm"
style={{ minWidth: 140 }}
defaultValue={semester}
value={semesterValue}
onChange={(event) => setSemesterValue(event.target.value)}
>
<option value=""></option>
<option value="Fall">Fall</option>
@@ -77,7 +77,11 @@ export function AcademicFilterBar({ schoolYears }: { schoolYears?: string[] }) {
<button
type="button"
className="btn btn-outline-secondary btn-sm ms-1"
onClick={() => navigate({ pathname, search: '' })}
onClick={() => {
setYearValue(selectedYear)
setSemesterValue('')
navigate({ pathname, search: '' })
}}
>
Reset
</button>
@@ -172,7 +172,7 @@ export function DiscountApplyVoucherPage() {
aria-label="Select all"
/>
</th>
<th>Parent ID</th>
<th>School ID</th>
<th>Parent Name</th>
<th>Email</th>
<th>Has Discount?</th>
+1 -1
View File
@@ -143,7 +143,7 @@ export function ReverseDiscountPage() {
<thead>
<tr>
<th>Select</th>
<th>Parent ID</th>
<th>School ID</th>
<th>Name</th>
<th>Email</th>
<th>Has discount?</th>
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchEnrollmentWithdrawalPage,
postEnrollmentWithdrawalAssignClass,
@@ -88,6 +89,10 @@ export function EnrollmentWithdrawalPage() {
/** Pending edits: undefined means “use row value”. */
const [pendingStatus, setPendingStatus] = 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
@@ -282,8 +287,7 @@ export function EnrollmentWithdrawalPage() {
setBulkWorking(false)
}
const yearSelectValue =
schoolYearParam || selectedYear || (schoolYears.length > 0 ? schoolYears[0] : '')
const yearSelectValue = schoolYearParam || selectedYear || defaultYear
const semesterSelectValue = semesterParam
return (
@@ -326,10 +330,10 @@ export function EnrollmentWithdrawalPage() {
defaultValue={yearSelectValue}
key={`${yearSelectValue}-${semesterSelectValue}`}
>
{schoolYears.length > 0 ? (
schoolYears.map((y) => (
<option key={y} value={y}>
{y}
{options.length > 0 ? (
options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
+21 -6
View File
@@ -2,6 +2,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchParticipation, postParticipation } from '../../api/grading'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { GRADING_PATH } from './gradingPaths'
/** CI `grading/participation.php` */
@@ -11,6 +12,10 @@ function normalizeParticipationScore(value: unknown): number | string | null {
export function ParticipationPage() {
const [searchParams] = useSearchParams()
const requestedSchoolYear = searchParams.get('school_year') ?? ''
const { selectedYear } = useSchoolYearOptions({
preferredYear: requestedSchoolYear,
})
const [rows, setRows] = useState<
Array<{
student_id: number
@@ -24,18 +29,24 @@ export function ParticipationPage() {
const [title] = useState('Participation Scores')
const [sectionName, setSectionName] = useState('')
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 [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [tableSearch, setTableSearch] = useState('')
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(() => {
let c = false
setLoading(true)
fetchParticipation(searchParams)
fetchParticipation(participationSearchParams)
.then((p) => {
if (c) return
if (p && typeof p === 'object') {
@@ -57,7 +68,11 @@ export function ParticipationPage() {
setLocked(Boolean(o.scores_locked ?? o.scoresLocked ?? o.locked))
setSectionName(typeof o.class_section_name === 'string' ? o.class_section_name : '')
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)
})
@@ -70,7 +85,7 @@ export function ParticipationPage() {
return () => {
c = true
}
}, [searchParams])
}, [effectiveSchoolYear, participationSearchParams, searchParams])
const visibleRows = useMemo(() => {
const normalized = tableSearch.toLowerCase().trim()
@@ -95,7 +110,7 @@ export function ParticipationPage() {
try {
const body: Record<string, unknown> = {
class_section_id: classSectionId ? Number(classSectionId) : undefined,
school_year: schoolYear || undefined,
school_year: effectiveSchoolYear || undefined,
semester: semester || undefined,
scores: rows.reduce<Record<string, { score: number | string | null }>>((carry, row) => {
carry[String(row.student_id)] = { score: row.score ?? '' }
@@ -103,7 +118,7 @@ export function ParticipationPage() {
}, {}),
}
await postParticipation(body)
const next = await fetchParticipation(searchParams)
const next = await fetchParticipation(participationSearchParams)
if (next && typeof next === 'object') {
const o = next as Record<string, unknown>
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 { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchInventorySummary } from '../../api/inventory'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
@@ -20,6 +21,10 @@ export function InventorySummaryPage() {
const [schoolYears, setSchoolYears] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { options, currentYear: canonicalCurrentYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: selectedYear,
})
const load = useCallback(() => {
setLoading(true)
@@ -94,19 +99,19 @@ export function InventorySummaryPage() {
value={
selectedYear.toLowerCase() === 'all'
? 'all'
: selectedYear === currentYear && !searchParams.get('school_year')
: selectedYear === (currentYear || canonicalCurrentYear) && !searchParams.get('school_year')
? '__current__'
: selectedYear
}
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>
{schoolYears
.filter((y) => y !== currentYear)
.map((y) => (
<option key={y} value={y}>
{y}
{options
.filter((option) => option.value !== (currentYear || canonicalCurrentYear))
.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
@@ -1,6 +1,7 @@
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchTeacherBookDistribute, postTeacherBookDistribute } from '../../api/inventory'
type BookGroup = { label: string; items: Record<string, unknown>[] }
@@ -23,6 +24,9 @@ export function TeacherBookDistributePage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const { selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
const load = useCallback(() => {
setLoading(true)
@@ -74,6 +78,8 @@ export function TeacherBookDistributePage() {
setSelected((prev) => ({ ...prev, [k]: checked }))
}
const effectiveSchoolYear = schoolYear || selectedYear
function checkAll(on: boolean) {
const next: Record<string, boolean> = {}
if (on) {
@@ -179,7 +185,7 @@ export function TeacherBookDistributePage() {
<div className="alert alert-info py-2">
<div className="d-flex justify-content-between flex-wrap gap-2">
<div>
<strong>School Year:</strong> {schoolYear}, <strong>Semester:</strong> {semester}
<strong>School Year:</strong> {effectiveSchoolYear || '—'}, <strong>Semester:</strong> {semester}
</div>
<div>
<strong>On Hand:</strong> {onHand}
@@ -239,7 +245,7 @@ export function TeacherBookDistributePage() {
{String(student.semester ?? semester)}
</td>
<td style={{ textAlign: 'center' }}>
{String(student.school_year ?? schoolYear)}
{String(student.school_year ?? effectiveSchoolYear)}
</td>
<td style={{ textAlign: 'center' }}>
{given ? (
+4
View File
@@ -5,12 +5,16 @@ export { InventoryBookIndexPage } from './InventoryBookIndexPage'
export { InventoryClassroomAuditPage } from './InventoryClassroomAuditPage'
export { InventoryClassroomFormPage } from './InventoryClassroomFormPage'
export { InventoryClassroomIndexPage } from './InventoryClassroomIndexPage'
export { InventoryDashboardPage } from './InventoryDashboardPage'
export { InventoryEditEntryPage } from './InventoryEditEntryPage'
export { InventoryKitchenFormPage } from './InventoryKitchenFormPage'
export { InventoryKitchenIndexPage } from './InventoryKitchenIndexPage'
export { InventoryLowStockPage } from './InventoryLowStockPage'
export { InventoryMovementFormPage } from './InventoryMovementFormPage'
export { InventoryMovementsIndexPage } from './InventoryMovementsIndexPage'
export { InventoryOfficeFormPage } from './InventoryOfficeFormPage'
export { InventoryOfficeIndexPage } from './InventoryOfficeIndexPage'
export { InventoryReorderSuggestionsPage } from './InventoryReorderSuggestionsPage'
export { InventoryStockStatusPage } from './InventoryStockStatusPage'
export { InventorySummaryPage } from './InventorySummaryPage'
export { TeacherBookDistributePage } from './TeacherBookDistributePage'
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchInvoiceManagement,
type InvoiceManagementRow,
@@ -37,6 +38,10 @@ export function InvoiceManagementPage() {
const [generating, setGenerating] = useState<number | null>(null)
const [error, setError] = useState<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) => {
setLoading(true)
@@ -47,7 +52,7 @@ export function InvoiceManagementPage() {
const data = await fetchInvoiceManagement(sp)
const years = Array.isArray(data.schoolYears) ? data.schoolYears : []
setSchoolYears(years)
const sel = data.schoolYear ?? years[0] ?? ''
const sel = data.schoolYear ?? years[0] ?? defaultYear
setSchoolYear(sel)
setInvoices(Array.isArray(data.invoices) ? data.invoices : [])
} catch (e: unknown) {
@@ -56,7 +61,7 @@ export function InvoiceManagementPage() {
} finally {
setLoading(false)
}
}, [])
}, [defaultYear])
useEffect(() => {
void load()
@@ -133,9 +138,9 @@ export function InvoiceManagementPage() {
value={schoolYear}
onChange={(e) => void onYearChange(e.target.value)}
>
{schoolYears.map((y) => (
<option key={y} value={y}>
{y}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+9 -13
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchChargeInvoicesForParent,
fetchExtraChargesPage,
@@ -33,6 +34,10 @@ export function ExtraChargesPage() {
const [invoiceOptions, setInvoiceOptions] = useState<Array<{ id?: number; invoice_number?: string }>>([])
const [modalOpen, setModalOpen] = useState(false)
const [selectedParentId, setSelectedParentId] = useState<string>('')
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: activeYear || schoolYear,
})
useEffect(() => {
setActiveYear(schoolYear)
@@ -101,15 +106,6 @@ export function ExtraChargesPage() {
let pageTotal = 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 (
<div className="container-fluid py-4">
{msg ? <div className="alert alert-info">{msg}</div> : null}
@@ -130,12 +126,12 @@ export function ExtraChargesPage() {
id="schoolYearSel"
className="form-select form-select-sm rounded-pill px-3"
style={{ minWidth: 180 }}
value={activeYear || years[0] || ''}
value={activeYear || defaultYear}
onChange={onYearChange}
>
{years.map((y) => (
<option key={y} value={y}>
{y}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+10 -14
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
downloadFinancialReportCsv,
fetchFinancialReportDetailed,
@@ -29,6 +30,10 @@ export function FinancialReportPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastRef, setLastRef] = useState<string | null>(null)
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
const hydrate = useCallback(() => {
setLoading(true)
@@ -133,15 +138,6 @@ export function FinancialReportPage() {
const totalCheck = Number(pt.total_check ?? 0)
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 (
<div className="container-fluid mt-4">
<h2 className="text-center mt-4 mb-3">Detailed Financial Report</h2>
@@ -158,12 +154,12 @@ export function FinancialReportPage() {
name="school_year"
className="form-select form-select-sm rounded-pill px-3"
style={{ minWidth: 180 }}
value={schoolYear || yearOptions[0] || ''}
value={schoolYear || defaultYear}
onChange={(e) => setSchoolYear(e.target.value)}
>
{yearOptions.map((sy) => (
<option key={sy} value={sy}>
{sy}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
@@ -221,7 +217,7 @@ export function FinancialReportPage() {
</button>
<Link
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
</Link>
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
downloadFinancialSummaryPdf,
fetchFinancialReportSummary,
@@ -19,6 +20,9 @@ export function FinancialReportSummaryPage() {
const [d, setD] = useState<FinancialSummaryJson | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear || urlYear,
})
function load(y: string) {
setLoading(true)
@@ -87,7 +91,7 @@ export function FinancialReportSummaryPage() {
name="school_year"
className="form-select form-select-sm rounded-pill px-3"
style={{ minWidth: 180 }}
value={schoolYear}
value={schoolYear || selectedYear}
onChange={(e) => {
const y = e.target.value
setSchoolYear(y)
@@ -96,15 +100,11 @@ export function FinancialReportSummaryPage() {
setSearchParams(next)
}}
>
{(() => {
const years: string[] = []
for (let y = new Date().getFullYear(); y >= 2020; y--) years.push(`${y}-${y + 1}`)
return years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))
})()}
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</form>
<div className="d-flex align-items-center gap-2 flex-nowrap flex-shrink-0">
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
downloadBatchCsv,
downloadReimbursementsExport,
@@ -92,6 +93,10 @@ export function ReimbursementsIndexPage() {
}
const schoolYears = data?.schoolYears ?? []
const { options } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
const users = data?.users ?? []
const batchSummaries = (data?.batchSummaries ?? []) as BatchSummaryRow[]
const batchDetails = data?.batchDetails ?? {}
@@ -188,9 +193,9 @@ export function ReimbursementsIndexPage() {
<label className="form-label">School Year</label>
<select name="school_year" className="form-select" defaultValue={schoolYear}>
<option value="">All</option>
{schoolYears.map((sy) => (
<option key={sy} value={sy}>
{sy}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
+12 -8
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchCombinedReport } from '../../api/reportsCombined'
import { REPORT_COMBINED_PATH } from './reportPaths'
@@ -275,6 +276,10 @@ export function CombinedReportPage() {
cursor: 'pointer',
userSelect: 'none' as const,
}
const { options, selectedYear: defaultYear } = useSchoolYearOptions({
preferredYear: selectedYear,
})
const schoolYearOptions = options.length > 0 ? options : [{ value: defaultYear, label: defaultYear }]
return (
<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}>
<div className="col-md-6 col-lg-4">
<label htmlFor="school_year" className="form-label">School Year</label>
<input
id="school_year"
type="text"
name="school_year"
className="form-control"
defaultValue={selectedYear}
placeholder="e.g. 2025-2026"
/>
<select id="school_year" name="school_year" className="form-select" defaultValue={selectedYear || defaultYear}>
{schoolYearOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-md-6 col-lg-4">
+7 -2
View File
@@ -5,6 +5,7 @@ import {
fetchBadgeScanLogs,
type BadgeScanLogRow,
} from '../../api/badgeScan'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
function formatDateTime(raw: string | null): string {
if (!raw) return '—'
@@ -80,6 +81,10 @@ export function BadgeScanLogsPage() {
() => [...new Set(logs.map((r) => r.school_year).filter(Boolean))].sort().reverse() as string[],
[logs],
)
const { options } = useSchoolYearOptions({
legacyYears: schoolYears,
preferredYear: schoolYear,
})
return (
<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>
<select id="bsl-school-year" name="school_year" className="form-select form-select-sm" defaultValue={schoolYear}>
<option value=""> All </option>
{schoolYears.map((sy) => (
<option key={sy} value={sy}>{sy}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchScorePrediction, type ScorePredictionStudent } from '../../api/scoreAnalysis'
import { FailRiskLabel, StatusBadge, TrophyChanceLabel } from './scorePredictionUi'
@@ -15,6 +16,9 @@ export function ScorePredictionPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof fetchScorePrediction>> | null>(null)
const [error, setError] = useState<string | null>(null)
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYearParam,
})
useEffect(() => {
setError(null)
@@ -125,6 +129,7 @@ export function ScorePredictionPage() {
}
const syDefault = data?.school_year ?? schoolYearParam
const schoolYearOptions = options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]
const statusColors = ['#198754', '#dc3545', '#0d6efd', '#ffc107', '#6c757d']
const riskColors = ['#198754', '#ffc107', '#dc3545', '#6610f2', '#adb5bd']
@@ -148,13 +153,13 @@ export function ScorePredictionPage() {
<label htmlFor="school_year" className="form-label">
School Year
</label>
<input
type="text"
className="form-control"
id="school_year"
name="school_year"
defaultValue={syDefault}
/>
<select className="form-select" id="school_year" name="school_year" defaultValue={syDefault || selectedYear}>
{schoolYearOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-md-6 col-lg-4">
<label htmlFor="class_section_id" className="form-label">
+11 -7
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { fetchSlipPreviewList, type SlipPreviewRow } from '../../api/slips'
import { displaySemester, formatPreviewDateKey } from './slipFormatting'
import { SLIPS_PRINT_PATH } from './slipPaths'
@@ -60,6 +61,9 @@ export function SlipPreviewListPage() {
key: 'student_name',
direction: 'asc',
})
const { options, selectedYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
useEffect(() => {
setError(null)
@@ -159,13 +163,13 @@ export function SlipPreviewListPage() {
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}>
<div className="col-md-4">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
defaultValue={schoolYear}
placeholder="e.g. 2025-2026"
/>
<select name="school_year" className="form-select" 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 className="col-md-3">
<label className="form-label">Semester</label>
+15 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useAuth } from '../../auth/AuthProvider'
import { fetchSchoolYears } from '../../api/schoolYears'
import {
fetchCompetitionScoresIndex,
fetchParentEnrollments,
@@ -16,6 +17,7 @@ import {
semesterHeaderLabel,
sortSemesterKeys,
} from './scoreCardListUtils'
import { canonicalSchoolYearName } from '../../lib/schoolYearOptions'
const LS_CLASS = 'alrahma_teacher_score_card_class_section_id'
const LS_YEAR = 'alrahma_teacher_score_card_school_year'
@@ -41,16 +43,17 @@ async function resolveTeacherScoreCardContext(searchParams: URLSearchParams): Pr
classSectionId: number
schoolYear: string | null
}> {
const canonicalSchoolYear = await resolveDefaultSchoolYear()
const fromUrlC = Number(searchParams.get('class_section_id') ?? '0')
const fromUrlY = searchParams.get('school_year')?.trim() || null
if (fromUrlC > 0) {
return { classSectionId: fromUrlC, schoolYear: fromUrlY }
return { classSectionId: fromUrlC, schoolYear: fromUrlY ?? canonicalSchoolYear }
}
try {
const lsC = Number(localStorage.getItem(LS_CLASS) ?? '0')
const lsY = localStorage.getItem(LS_YEAR)?.trim() || null
if (lsC > 0) {
return { classSectionId: lsC, schoolYear: fromUrlY ?? lsY }
return { classSectionId: lsC, schoolYear: fromUrlY ?? lsY ?? canonicalSchoolYear }
}
} catch {
/* ignore */
@@ -63,16 +66,16 @@ async function resolveTeacherScoreCardContext(searchParams: URLSearchParams): Pr
if (cid > 0) {
try {
localStorage.setItem(LS_CLASS, String(cid))
if (sy) localStorage.setItem(LS_YEAR, sy)
if (sy ?? canonicalSchoolYear) localStorage.setItem(LS_YEAR, sy ?? canonicalSchoolYear ?? '')
} catch {
/* ignore */
}
return { classSectionId: cid, schoolYear: sy }
return { classSectionId: cid, schoolYear: sy ?? canonicalSchoolYear }
}
} catch {
/* ignore */
}
return { classSectionId: 0, schoolYear: fromUrlY }
return { classSectionId: 0, schoolYear: fromUrlY ?? canonicalSchoolYear }
}
function filterStudentsToClassSection(
@@ -86,6 +89,13 @@ function filterStudentsToClassSection(
}
async function resolveDefaultSchoolYear(): Promise<string | null> {
try {
const years = await fetchSchoolYears()
const canonical = canonicalSchoolYearName(years)
if (canonical) return canonical
} catch {
/* ignore */
}
try {
const comp = await fetchCompetitionScoresIndex(null)
const sy = comp.schoolYear != null && String(comp.schoolYear).trim() !== '' ? String(comp.schoolYear).trim() : null
+20 -3
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import {
fetchTeacherDashboard,
isTeacherDashboardApiEnabled,
type TeacherDashboardPayload,
} from '../../api/teacherSession'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
@@ -18,6 +19,16 @@ type SentPayload = { sentMessages?: MsgRow[] }
type TeacherMessagePayload = { message?: Record<string, unknown>; thread?: unknown[] }
function appendSchoolYearParam(path: string, schoolYear: string): string {
if (!schoolYear) return path
const [base, hash = ''] = path.split('#', 2)
const [pathname, query = ''] = base.split('?', 2)
const params = new URLSearchParams(query)
if (!params.get('school_year')) params.set('school_year', schoolYear)
const next = `${pathname}?${params.toString()}`
return hash ? `${next}#${hash}` : next
}
/** Every `/app/teacher/*` route so nothing is hidden behind the slim top nav alone. */
const TEACHER_PAGE_GROUPS = [
{
@@ -204,14 +215,20 @@ export function TeacherNoClassesPage() {
/** `teacher/select_semester.php` */
export function TeacherSelectSemesterPage() {
const [searchParams] = useSearchParams()
const { data, loading, error } = useTeacherResource<{
invalidChoice?: string
fallPath?: string
springPath?: string
}>('/select-semester')
const requestedSchoolYear = searchParams.get('school_year')?.trim() ?? ''
const { selectedYear } = useSchoolYearOptions({
preferredYear: requestedSchoolYear,
})
const activeSchoolYear = requestedSchoolYear || selectedYear
const fallTo = data?.fallPath ?? '/app/teacher/scores?semester=Fall'
const springTo = data?.springPath ?? '/app/teacher/scores?semester=Spring'
const fallTo = appendSchoolYearParam(data?.fallPath ?? '/app/teacher/scores?semester=Fall', activeSchoolYear)
const springTo = appendSchoolYearParam(data?.springPath ?? '/app/teacher/scores?semester=Spring', activeSchoolYear)
return (
<TeacherShell title="Choose a semester" loading={loading} error={error}>
+76 -10
View File
@@ -7,6 +7,7 @@ import { useTeacherResource } from './useTeacherResource'
import { postTeacherMultipart } from '../../api/teacherSession'
import { useAuth } from '../../auth/AuthProvider'
import { ApiHttpError, apiFetch } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
function TeacherApiDumpPage({
title,
@@ -29,6 +30,20 @@ function TeacherApiDumpPage({
)
}
function teacherRouteWithTerm(
path: string,
params: { classSectionId?: number; schoolYear?: string; semester?: string },
): string {
const query = new URLSearchParams()
if (params.classSectionId && params.classSectionId > 0) {
query.set('class_section_id', String(params.classSectionId))
}
if (params.schoolYear) query.set('school_year', params.schoolYear)
if (params.semester) query.set('semester', params.semester)
const value = query.toString()
return value ? `${path}?${value}` : path
}
type CompetitionIndexPayload = {
success?: string
error?: string
@@ -1372,6 +1387,10 @@ export function TeacherScoresPage() {
const [search] = useSearchParams()
const requestedSemester = search.get('semester')?.trim() ?? ''
const requestedSchoolYear = search.get('school_year')?.trim() ?? ''
const { selectedYear: defaultSchoolYear } = useSchoolYearOptions({
preferredYear: requestedSchoolYear,
})
const effectiveRequestedSchoolYear = requestedSchoolYear || defaultSchoolYear
const [payload, setPayload] = useState<TeacherScoresPayload | null>(null)
const [rows, setRows] = useState<TeacherScoresRow[]>([])
@@ -1384,7 +1403,7 @@ export function TeacherScoresPage() {
const [tableSearch, setTableSearch] = useState('')
const resolvedSemester = payload?.semester?.trim() || requestedSemester
const resolvedSchoolYear = payload?.school_year?.trim() || requestedSchoolYear
const resolvedSchoolYear = payload?.school_year?.trim() || effectiveRequestedSchoolYear
const semesterKey = teacherSemesterKey(resolvedSemester)
const termLabel = semesterKey === 'midterm' ? 'Midterm' : semesterKey === 'final' ? 'Final Exam' : 'Term Score'
const termCommentLabel = semesterKey === 'midterm' ? 'Midterm Comment' : semesterKey === 'final' ? 'Final Comment' : 'Term Comment'
@@ -1464,7 +1483,7 @@ export function TeacherScoresPage() {
setWarningLines([])
const params = new URLSearchParams()
if (requestedSemester !== '') params.set('semester', requestedSemester)
if (requestedSchoolYear !== '') params.set('school_year', requestedSchoolYear)
if (effectiveRequestedSchoolYear !== '') params.set('school_year', effectiveRequestedSchoolYear)
const query = params.toString()
const body = await apiFetch<TeacherScoresPayload>(`/api/v1/teacher/scores${query ? `?${query}` : ''}`)
setPayload(body)
@@ -1478,7 +1497,7 @@ export function TeacherScoresPage() {
useEffect(() => {
void load()
}, [requestedSchoolYear, requestedSemester])
}, [effectiveRequestedSchoolYear, requestedSemester])
const visibleRows = useMemo(() => {
const needle = tableSearch.trim().toLowerCase()
@@ -1674,7 +1693,12 @@ export function TeacherScoresPage() {
) : null}
<div className="text-center mb-3">
<Link to="/app/teacher/select-semester" className="btn btn-outline-secondary btn-sm">
<Link
to={teacherRouteWithTerm('/app/teacher/select-semester', {
schoolYear: resolvedSchoolYear,
})}
className="btn btn-outline-secondary btn-sm"
>
Choose another semester
</Link>
</div>
@@ -1807,25 +1831,67 @@ export function TeacherScoresPage() {
</div>
<div className="d-flex flex-wrap gap-2 mt-3">
<Link to={`/app/teacher/add-homework?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-homework', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Homework
</Link>
<Link to={`/app/teacher/add-quiz?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-quiz', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Quiz
</Link>
<Link to={`/app/teacher/add-project?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-project', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Project
</Link>
<Link to={`/app/teacher/add-participation?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-participation', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Participation
</Link>
{semesterKey === 'midterm' ? (
<Link to={`/app/teacher/add-midterm-exam?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-midterm-exam', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Midterm
</Link>
) : null}
{semesterKey === 'final' ? (
<Link to={`/app/teacher/add-final-exam?class_section_id=${classSectionId}`} className="btn btn-secondary">
<Link
to={teacherRouteWithTerm('/app/teacher/add-final-exam', {
classSectionId,
schoolYear: resolvedSchoolYear,
semester: resolvedSemester,
})}
className="btn btn-secondary"
>
Add Final
</Link>
) : null}
+555
View File
@@ -0,0 +1,555 @@
import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
import {
fetchTrophyFinal,
fetchTrophyProjection,
fetchTrophyWinners,
type TrophyFinalPayload,
type TrophyProjectionPayload,
type TrophyStudent,
type TrophyWinnersClassResult,
type TrophyWinnersPayload,
} from '../../api/trophy'
function formatScore(value?: number | null) {
return value == null || Number.isNaN(value) ? '—' : value.toFixed(1)
}
function isMale(gender?: string | null) {
const normalized = String(gender ?? '').trim().toLowerCase()
return ['male', 'm', 'boy', 'boys'].includes(normalized)
}
function buildSearch(params: { school_year?: string; percentile?: string | number }) {
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))
}
const value = qs.toString()
return value ? `?${value}` : ''
}
function TrophyHeader({
title,
subtitle,
actions,
}: {
title: string
subtitle: string
actions?: ReactNode
}) {
return (
<div className="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-4">
<div>
<h2 className="mb-1">{title}</h2>
<p className="text-muted mb-0">{subtitle}</p>
</div>
{actions ? <div className="d-flex gap-2 flex-wrap">{actions}</div> : null}
</div>
)
}
function TrophyFilters({
years,
selectedYear,
selectedPercentile,
onSubmit,
}: {
years: string[]
selectedYear: string
selectedPercentile: number
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}) {
const { options } = useSchoolYearOptions({
legacyYears: years,
preferredYear: selectedYear,
})
return (
<form className="row g-3 align-items-end mb-4" onSubmit={onSubmit}>
<div className="col-md-4 col-lg-3">
<label className="form-label">School Year</label>
<select className="form-select" name="school_year" defaultValue={selectedYear}>
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((year) => (
<option key={year.value} value={year.value}>
{year.label}
</option>
))}
</select>
</div>
<div className="col-md-4 col-lg-3">
<label className="form-label">Percentile</label>
<div className="input-group">
<input
className="form-control"
type="number"
min="1"
max="99"
step="1"
name="percentile"
defaultValue={Math.round(selectedPercentile)}
/>
<span className="input-group-text">%</span>
</div>
</div>
<div className="col-md-4 col-lg-2">
<button className="btn btn-primary w-100" type="submit">
Apply
</button>
</div>
</form>
)
}
function useTrophySearchParams() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const percentile = searchParams.get('percentile') ?? ''
function applyFilters(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const next = new URLSearchParams()
const year = String(formData.get('school_year') ?? '').trim()
const pct = String(formData.get('percentile') ?? '').trim()
if (year) next.set('school_year', year)
if (pct) next.set('percentile', pct)
setSearchParams(next)
}
return {
schoolYear,
percentile,
applyFilters,
}
}
function SummaryCard({
label,
value,
detail,
tone = 'primary',
}: {
label: string
value: string | number
detail?: string
tone?: 'primary' | 'success' | 'warning' | 'secondary' | 'info' | 'dark'
}) {
return (
<div className="col-6 col-lg-2">
<div className={`card border-${tone} shadow-sm h-100`}>
<div className="card-body py-3">
<div className={`small text-${tone} text-uppercase fw-semibold mb-1`}>{label}</div>
<div className="fs-3 fw-semibold">{value}</div>
{detail ? <div className="small text-muted mt-1">{detail}</div> : null}
</div>
</div>
</div>
)
}
function StudentGenderBadge({ gender }: { gender?: string | null }) {
const male = isMale(gender)
return (
<span
className="badge"
style={{ backgroundColor: male ? '#4A90E2' : '#E47AB0' }}
>
{male ? 'M' : 'F'}
</span>
)
}
export function TrophyProjectionPage() {
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
const [data, setData] = useState<TrophyProjectionPayload | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setError(null)
fetchTrophyProjection({
school_year: schoolYear || undefined,
percentile: percentile || undefined,
})
.then((payload) => {
if (!cancelled) setData(payload)
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load trophy projection.')
}
})
return () => {
cancelled = true
}
}, [schoolYear, percentile])
const selectedYear = data?.selected_year ?? schoolYear
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
const summary = data?.summary ?? {}
return (
<div className="container-fluid">
<TrophyHeader
title="Trophy Projections"
subtitle="Fall scores determine the projected year-end trophy threshold for each class."
actions={
<>
<Link className="btn btn-warning" to={`/app/administrator/trophy_winners${search}`}>
Reveal Winners
</Link>
<Link className="btn btn-success" to={`/app/administrator/trophy_final${search}`}>
Final vs Predicted
</Link>
</>
}
/>
{error ? <div className="alert alert-danger">{error}</div> : null}
<TrophyFilters
years={data?.years ?? []}
selectedYear={selectedYear}
selectedPercentile={selectedPercentile}
onSubmit={applyFilters}
/>
<div className="row g-3 mb-4">
<SummaryCard label="Classes" value={summary.classes ?? 0} tone="secondary" />
<SummaryCard label="Students" value={summary.students ?? 0} tone="primary" />
<SummaryCard label="Scored" value={summary.scored ?? 0} detail={`${summary.pct_scored ?? 0}% roster`} tone="success" />
<SummaryCard label="Trophies" value={summary.trophies ?? 0} detail={`${summary.pct_trophies ?? 0}% roster`} tone="warning" />
<SummaryCard label="Boys" value={summary.boys ?? 0} detail={`${summary.pct_boys ?? 0}%`} tone="info" />
<SummaryCard label="Girls" value={summary.girls ?? 0} detail={`${summary.pct_girls ?? 0}%`} tone="dark" />
</div>
{data && data.class_results.length === 0 ? (
<div className="alert alert-info">No class or fall-score data found for the selected school year.</div>
) : null}
{data?.class_results?.length ? (
<div className="card shadow-sm">
<div className="card-header bg-white fw-semibold">Class Breakdown</div>
<div className="table-responsive">
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
<thead className="table-light">
<tr>
<th>Class</th>
<th className="text-center">Students</th>
<th className="text-center">Scored</th>
<th className="text-center">Trophies</th>
<th className="text-center">Boys</th>
<th className="text-center">Girls</th>
<th className="text-center">Trophy Boys</th>
<th className="text-center">Trophy Girls</th>
<th className="text-end">Threshold</th>
</tr>
</thead>
<tbody>
{data.class_results.map((row) => (
<tr key={row.section_id}>
<td className="fw-semibold">{row.section_name}</td>
<td className="text-center">{row.student_count}</td>
<td className="text-center">{row.scored_count}</td>
<td className="text-center">
<span className="badge text-bg-warning">{row.trophy_count}</span>
</td>
<td className="text-center">
{row.boys} <span className="text-muted small">({row.pct_boys}%)</span>
</td>
<td className="text-center">
{row.girls} <span className="text-muted small">({row.pct_girls}%)</span>
</td>
<td className="text-center">
{row.trophy_boys} <span className="text-muted small">({row.pct_trophy_boys}%)</span>
</td>
<td className="text-center">
{row.trophy_girls} <span className="text-muted small">({row.pct_trophy_girls}%)</span>
</td>
<td className="text-end text-muted">{formatScore(row.threshold)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</div>
)
}
function WinnersTable({
rows,
}: {
rows: TrophyWinnersClassResult[]
}) {
return (
<>
{rows.map((section) => (
<section key={section.section_id} className="card shadow-sm mb-4">
<div className="card-header d-flex flex-wrap justify-content-between gap-2">
<div className="fw-semibold">{section.section_name}</div>
<div className="small text-muted">
Threshold {formatScore(section.threshold)} | {section.winners.length}/{section.student_count} winners
</div>
</div>
<div className="table-responsive">
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
<thead className="table-light">
<tr>
<th style={{ width: 70 }}>#</th>
<th>Name</th>
<th style={{ width: 90 }}>Gender</th>
<th style={{ width: 140 }}>School ID</th>
<th className="text-end" style={{ width: 120 }}>Fall</th>
<th className="text-end" style={{ width: 120 }}>Spring</th>
<th className="text-end" style={{ width: 120 }}>Year</th>
</tr>
</thead>
<tbody>
{section.winners.map((student, index) => (
<tr key={`${section.section_id}-${student.student_id ?? index}`}>
<td>{index + 1}</td>
<td className="fw-semibold">{student.name}</td>
<td><StudentGenderBadge gender={student.gender} /></td>
<td>{student.school_id ?? '—'}</td>
<td className="text-end">{formatScore(student.fall_score)}</td>
<td className="text-end">{formatScore(student.spring_score)}</td>
<td className="text-end">{formatScore(student.year_score)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</>
)
}
export function TrophyWinnersPage() {
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
const [data, setData] = useState<TrophyWinnersPayload | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setError(null)
fetchTrophyWinners({
school_year: schoolYear || undefined,
percentile: percentile || undefined,
})
.then((payload) => {
if (!cancelled) setData(payload)
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load trophy winners.')
}
})
return () => {
cancelled = true
}
}, [schoolYear, percentile])
const selectedYear = data?.selected_year ?? schoolYear
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
const summary = data?.summary ?? {}
return (
<div className="container-fluid">
<TrophyHeader
title="Trophy Winners"
subtitle={`${selectedYear || 'Current year'} projected trophy winners at the ${Math.round(selectedPercentile)}th percentile.`}
actions={
<>
<Link className="btn btn-outline-secondary" to={`/app/administrator/trophy${search}`}>
Back to Projection
</Link>
<Link className="btn btn-success" to={`/app/administrator/trophy_final${search}`}>
Final vs Predicted
</Link>
</>
}
/>
{error ? <div className="alert alert-danger">{error}</div> : null}
<TrophyFilters
years={data?.years ?? []}
selectedYear={selectedYear}
selectedPercentile={selectedPercentile}
onSubmit={applyFilters}
/>
<div className="row g-3 mb-4">
<SummaryCard label="Classes" value={summary.classes ?? 0} tone="secondary" />
<SummaryCard label="Students" value={summary.students ?? 0} tone="primary" />
<SummaryCard label="Winners" value={summary.winners ?? 0} detail={`${summary.pct_winners ?? 0}% roster`} tone="warning" />
<SummaryCard label="Trophy Boys" value={summary.trophy_boys ?? 0} detail={`${summary.pct_trophy_boys ?? 0}% boys`} tone="info" />
<SummaryCard label="Trophy Girls" value={summary.trophy_girls ?? 0} detail={`${summary.pct_trophy_girls ?? 0}% girls`} tone="dark" />
</div>
{data && data.class_results.length === 0 ? (
<div className="alert alert-info">No projected trophy winners found.</div>
) : null}
{data?.class_results?.length ? <WinnersTable rows={data.class_results} /> : null}
</div>
)
}
function StatusBadge({ status }: { status?: string }) {
if (status === 'confirmed') return <span className="badge bg-success">Confirmed</span>
if (status === 'surprise') return <span className="badge bg-info text-dark">Surprise</span>
if (status === 'missed') return <span className="badge text-bg-warning">Missed</span>
return <span className="badge bg-light text-dark border">None</span>
}
function FinalStatusRows({ students }: { students: TrophyStudent[] }) {
return students.filter((student) => student.status && student.status !== 'none')
}
export function TrophyFinalPage() {
const { schoolYear, percentile, applyFilters } = useTrophySearchParams()
const [data, setData] = useState<TrophyFinalPayload | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setError(null)
fetchTrophyFinal({
school_year: schoolYear || undefined,
percentile: percentile || undefined,
})
.then((payload) => {
if (!cancelled) setData(payload)
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Failed to load final trophy report.')
}
})
return () => {
cancelled = true
}
}, [schoolYear, percentile])
const selectedYear = data?.selected_year ?? schoolYear
const selectedPercentile = data?.selected_percentile ?? Number(percentile || 75)
const search = buildSearch({ school_year: selectedYear, percentile: selectedPercentile })
const summary = data?.summary ?? {}
const winnerGender = data?.winner_gender_summary
return (
<div className="container-fluid">
<TrophyHeader
title="Final vs Predicted"
subtitle="Compares fall-score trophy projections with year-end trophy outcomes."
actions={
<>
<Link className="btn btn-outline-secondary" to={`/app/administrator/trophy${search}`}>
Projection
</Link>
<Link className="btn btn-warning" to={`/app/administrator/trophy_winners${search}`}>
Winners
</Link>
</>
}
/>
{error ? <div className="alert alert-danger">{error}</div> : null}
<TrophyFilters
years={data?.years ?? []}
selectedYear={selectedYear}
selectedPercentile={selectedPercentile}
onSubmit={applyFilters}
/>
<div className="row g-3 mb-4">
<SummaryCard label="Predicted" value={summary.predicted ?? 0} detail={`${summary.pct_predicted ?? 0}% roster`} tone="primary" />
<SummaryCard label="Actual" value={summary.actual ?? 0} detail={`${summary.pct_actual ?? 0}% roster`} tone="warning" />
<SummaryCard label="Confirmed" value={summary.confirmed ?? 0} detail={`${summary.pct_confirmed_of_predicted ?? 0}% of predicted`} tone="success" />
<SummaryCard label="Surprises" value={summary.surprises ?? 0} tone="info" />
<SummaryCard label="Missed" value={summary.missed ?? 0} tone="dark" />
<SummaryCard label="Accuracy" value={`${summary.accuracy ?? 0}%`} tone="secondary" />
</div>
{winnerGender ? (
<div className="card shadow-sm mb-4">
<div className="card-header bg-white fw-semibold">Winner Gender Breakdown</div>
<div className="card-body">
<div className="row g-3">
<SummaryCard label="Total Winners" value={winnerGender.total} tone="secondary" />
<SummaryCard label="Boys" value={winnerGender.boys} detail={`${winnerGender.pct_boys}%`} tone="info" />
<SummaryCard label="Girls" value={winnerGender.girls} detail={`${winnerGender.pct_girls}%`} tone="dark" />
</div>
</div>
</div>
) : null}
{data && data.class_results.length === 0 ? (
<div className="alert alert-info">No data found for the selected school year.</div>
) : null}
{data?.class_results?.map((section) => {
const rows = FinalStatusRows({ students: section.students })
return (
<section key={section.section_id} className="card shadow-sm mb-4">
<div className="card-header d-flex flex-wrap justify-content-between gap-2">
<div className="fw-semibold">{section.section_name}</div>
<div className="small text-muted">
Fall {formatScore(section.fall_threshold)} | Year {formatScore(section.year_threshold)} | Accuracy {section.accuracy}%
</div>
</div>
<div className="table-responsive">
<table className="table table-sm table-hover mb-0" data-no-mgmt-sticky>
<thead className="table-light">
<tr>
<th>Name</th>
<th style={{ width: 90 }}>Gender</th>
<th className="text-end" style={{ width: 110 }}>Fall</th>
<th className="text-end" style={{ width: 110 }}>Spring</th>
<th className="text-end" style={{ width: 110 }}>Year</th>
<th className="text-center" style={{ width: 110 }}>Predicted</th>
<th className="text-center" style={{ width: 110 }}>Actual</th>
<th className="text-center" style={{ width: 120 }}>Status</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="text-center text-muted py-4">
No trophy changes in this class.
</td>
</tr>
) : (
rows.map((student) => (
<tr key={`${section.section_id}-${student.student_id}`}>
<td className="fw-semibold">{student.name}</td>
<td><StudentGenderBadge gender={student.gender} /></td>
<td className="text-end">{formatScore(student.fall_score)}</td>
<td className="text-end">{formatScore(student.spring_score)}</td>
<td className="text-end">{formatScore(student.year_score)}</td>
<td className="text-center">{student.predicted ? 'Yes' : 'No'}</td>
<td className="text-center">{student.actual ? 'Yes' : 'No'}</td>
<td className="text-center"><StatusBadge status={student.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)
})}
</div>
)
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
/** Laravel API for dev proxy (`/api` → this host). Override: `VITE_PROXY_API=http://localhost:8000 npm run dev`. */
const apiTarget = process.env.VITE_PROXY_API ?? 'http://localhost:8080'
const apiTarget = process.env.VITE_PROXY_API ?? process.env.VITE_API_ORIGIN ?? 'http://localhost:8000'
// https://vite.dev/config/
export default defineConfig({
@@ -18,6 +18,7 @@ export default defineConfig({
'/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},