Compare commits

...

27 Commits

Author SHA1 Message Date
root c6d2bc007e fix first production issues
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 40s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 24s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-10 03:43:47 -04:00
root b895c06dc1 fix parent pages and teachers and admin
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-09 13:56:26 -04:00
root 4b78aafa65 fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 30s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-07 01:52:37 -04:00
root b510f534fd security fix and fix parent pages
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 22s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 32s
2026-07-06 02:14:22 -04:00
root b74ddca8b1 add school year and security fix
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 28s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-06 00:55:02 -04:00
root 8cacd0874f fix calendar page
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 32s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 47s
2026-07-05 14:18:49 -04:00
root 405dc3e379 fix(ci): remove GitHub-specific marketplace actions incompatible with Gitea act runner
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 18s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 14s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 15s
- Remove actions/cache@v4 (uses GitHub's artifact API not available in Gitea)
- Remove actions/upload-artifact@v4 (same reason)
- Remove actions/download-artifact@v4 (same reason)
- Deploy job now builds from source instead of downloading artifacts
- Deploy container changed from alpine:3.20 to node:22-alpine for npm build
2026-06-22 00:57:53 -04:00
root 816f4cfaf0 fix: resolve all CI lint errors (9 errors → 0)
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 40s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 36s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
- Move SortTh outside AdminEmergencyContactIndexPage to fix
  react-hooks/static-components (5 errors)
- Fix ternary expressions used as statements in toggleSection
  and toggleDate (2 @typescript-eslint/no-unused-expressions errors)
- Remove stale eslint-disable-next-line react/no-danger comment
  referencing unregistered rule (1 error)
- Fix pre-existing any type in WiredParentScreens student.map
2026-06-22 00:38:47 -04:00
root 7282020444 chore: configure eslint rules and fix lint warnings
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 41s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
- Add eslint rules section downgrading strict react-hooks v7 rules to warn
- Remove unnecessary Boolean() wrappers across multiple pages
- Fix let→const for non-reassigned variable in AdminProgressPages
- Clean up stale eslint-disable-next-line comment and trailing whitespace
- Simplify setTimeout declaration in ManualPayPage
2026-06-22 00:17:18 -04:00
root 1992db65b8 fix(ci): use github.server_url template expression instead of missing GITEA_SERVER_URL env
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 40s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-21 21:42:45 -04:00
root 32875e54a8 ci: trigger pipeline with new default branch
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 2s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 2s
2026-06-21 15:10:35 -04:00
root 14eade0b7e fix(ci): install git via apk before clone in node:22-alpine image
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 2s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 2s
2026-06-21 15:08:32 -04:00
root 1e2345323f fix(ci): replace actions/checkout with git clone + GIT_SSL_NO_VERIFY to bypass self-signed cert
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 1s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 1s
2026-06-21 15:06:54 -04:00
root c3570481c8 fix(ci): merge duplicate push triggers in workflow
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 26s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 40s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-21 12:00:34 -04:00
root 09dbca4767 fix: add alpine container to deploy job for shared hosting compatibility
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 38s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 32s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-20 19:20:45 -04:00
root 2eaf590373 docs: add Gitea CI/CD setup checklist
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 32s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to production (push) Has been skipped
2026-06-20 19:18:40 -04:00
root 4b6232d2ba ci: add Gitea CI/CD workflow for React web client
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 37s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 36s
Web Client CI/CD / Deploy to production (push) Has been skipped
Pipeline includes lint (eslint), build (tsc + vite), and manual
deploy stages.
2026-06-20 19:16:38 -04:00
root cd29394211 fix cicd variables naming 2026-06-12 13:14:53 -04:00
root 1eb45f46a8 add test script to package json 2026-06-12 12:23:43 -04:00
root 665b26df1e remove pupeteer and ad ci gitlab 2026-06-12 01:10:24 -04:00
root 7365c14506 fix inventory 2026-06-11 11:06:41 -04:00
root e8d1b8c1c7 page fixes 2026-06-11 03:22:22 -04:00
root fe1c50878b add test create 3000 users 2026-06-08 01:53:25 -04:00
root b6513ab22f update the new school year model 2026-06-07 20:01:56 -04:00
root 8e79201a3c add school year model 2026-06-07 00:52:04 -04:00
root c4d7a06a17 fix financial and certificates 2026-06-05 01:51:12 -04:00
root 647b96cafc fix attendance 2026-06-04 20:49:24 -04:00
108 changed files with 9933 additions and 2203 deletions
+49
View File
@@ -0,0 +1,49 @@
# Gitea CI/CD — Setup Checklist
After pushing, the pipeline will run automatically. The following steps are required to unlock all features (especially deploy jobs).
## 1. Repository Secrets (Settings → Actions → Secrets)
| Secret Name | Description |
|---|---|
| `SSH_PASSWORD` | SSH password for the deployment server |
| `SSH_USER` | SSH username for the deployment server |
| `SSH_HOST` | IP or domain of the deployment server |
## 2. Repository Variables (Settings → Actions → Variables)
| Variable Name | Default | Description |
|---|---|---|
| `SSH_PORT` | `22` | SSH port for the deployment server |
| `SSH_TARGET_DIR` | — | Absolute path on the server where `dist/` contents should be placed |
| `PRODUCTION_URL` | — | Public URL of the production site |
| `VITE_API_URL` | — | (Optional) API base URL passed at build time |
## 3. Allow Gitea Actions
- Go to **Settings → Actions** in your Gitea repository
- Ensure **"Actions"** are enabled
- If using self-hosted runners, ensure a runner is registered and online
## 4. Optional: Configure Gitea Actions Runner
If no runner is registered yet on your Gitea instance:
```bash
# On the runner machine (Docker-based)
docker run -d \
--name gitea-runner \
-e GITEA_INSTANCE_URL=https://192.168.3.80 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<your-registration-token> \
-e GITEA_RUNNER_NAME=runner-1 \
-v /var/run/docker.sock:/var/run/docker.sock \
gitea/act_runner:latest
```
## Pipeline Stages
| Job | Requires Secrets/Vars | Runs on |
|---|---|---|
| `lint` | None | Push & PR |
| `build` | None | Push & PR |
| `deploy` | SSH secrets + vars | Manual on `develop` |
+132
View File
@@ -0,0 +1,132 @@
# Gitea CI/CD for alrahma_web_client (React + Vite + TypeScript)
#
# Triggers:
# - Push to any branch
# - Pull requests
# - Tags
#
# Stages: lint → build → deploy (manual)
name: Web Client CI/CD
on:
push:
branches: [main, master, develop, 'feature/**', 'fix/**']
tags: ['v*']
pull_request:
branches: [main, master, develop]
env:
CI: true
jobs:
# ──────────────────────────────────────────────
# LINT: ESLint with TypeScript
# ──────────────────────────────────────────────
lint:
name: Lint (ESLint + TypeScript)
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Run ESLint
run: npm run lint
# ──────────────────────────────────────────────
# BUILD: TypeScript + Vite
# ──────────────────────────────────────────────
build:
name: Build (tsc + Vite)
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
# ──────────────────────────────────────────────
# DEPLOY (manual): to shared hosting via SSH
# ──────────────────────────────────────────────
deploy:
name: Deploy to shared hosting
runs-on: ubuntu-latest
container:
image: node:22-alpine
if: github.ref == 'refs/heads/develop'
needs: [lint, build]
environment:
name: production
url: ${{ vars.PRODUCTION_URL }}
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
- name: Install SSH and rsync
run: |
apk add --no-cache openssh-client sshpass rsync
- name: Deploy built files to shared hosting
run: |
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
-e "ssh -p ${{ vars.SSH_PORT || 22 }} -o StrictHostKeyChecking=no" \
--exclude='.htaccess' \
./dist/ \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ vars.SSH_TARGET_DIR }}/
+63
View File
@@ -0,0 +1,63 @@
stages:
- build
- deploy
variables:
CACHE_KEY: "node-modules-${CI_COMMIT_REF_SLUG}"
BUILD_DIR: "dist"
cache:
key: $CACHE_KEY
paths:
- node_modules/
policy: pull-push
# Build stage
build:
stage: build
image: node:20-alpine
script:
- echo "Installing dependencies..."
- npm ci
- echo "Building project..."
- npm run build
- echo "Build completed!"
artifacts:
paths:
- $BUILD_DIR/
expire_in: 1 hour
only:
- develop
# Deploy to shared hosting via FTP
deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client sshpass rsync
script:
- echo "Starting deployment to $SSH_HOST on port $SSH_PORT..."
- |
if sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
-e "ssh -p $SSH_PORT -o StrictHostKeyChecking=no" \
--exclude='.htaccess' \
--exclude='.env' \
--exclude='.git' \
$BUILD_DIR/ $SSH_USER@$SSH_HOST:$SSH_TARGET_DIR/; then
echo "✅ Deployment successful!"
echo "🌐 Website: $PRODUCTION_URL"
else
echo "❌ Deployment failed!"
exit 1
fi
environment:
name: production
url: $PRODUCTION_URL
only:
- develop
when: manual
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
+2 -2
View File
@@ -19,8 +19,8 @@ RUN npm install --no-audit --no-fund
COPY . .
ARG VITE_API_ORIGIN=
ENV VITE_API_ORIGIN=$VITE_API_ORIGIN
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
+2 -2
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'
@@ -21,7 +21,7 @@ services:
context: .
dockerfile: Dockerfile
args:
VITE_API_ORIGIN: ${VITE_API_ORIGIN:-}
VITE_API_URL: ${VITE_API_URL:-}
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
ports:
- '8080:80'
+12
View File
@@ -19,5 +19,17 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// ── New strict rules from react-hooks v7 — downgrade to warn ──
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
'react-hooks/immutability': 'warn',
'react-refresh/only-export-components': 'warn',
// ── Keep these as errors (real bugs / anti-patterns) ──
'react-hooks/static-components': 'error',
'@typescript-eslint/no-unused-expressions': 'error',
},
},
])
+561 -550
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@
"version": "0.0.0",
"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",
+28
View File
@@ -73,3 +73,31 @@ body {
color: #15654f;
font-weight: 600;
}
.school-year-selector {
max-width: 100%;
}
.school-year-selector .form-select {
min-width: 11rem;
}
.school-year-selector--compact .form-label {
color: inherit;
}
.school-year-selector--compact .form-select {
width: auto;
min-width: 10rem;
}
.navbar-dark .school-year-selector,
.custom-navbar .school-year-selector {
color: inherit;
}
.navbar-dark .school-year-selector .form-label,
.custom-navbar .school-year-selector .form-label {
color: inherit;
opacity: 0.85;
}
+96 -1
View File
@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider'
import { RequireAuth } from './auth/RequireAuth'
import { GlobalTableSorting } from './components/GlobalTableSorting'
import { SchoolYearProvider } from './context/SchoolYearContext'
import './App.css'
const PublicLayout = lazy(() =>
@@ -77,6 +78,26 @@ 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 SchoolYearDetailPage = lazy(() =>
import('./pages/administrator/SchoolYearDetailPage').then((m) => ({
default: m.SchoolYearDetailPage,
})),
)
const SchoolYearClosingReportPage = lazy(() =>
import('./pages/administrator/SchoolYearReportsPage').then((m) => ({
default: () => <m.SchoolYearReportsPage reportType="closing" />,
})),
)
const SchoolYearBalanceTransfersReportPage = lazy(() =>
import('./pages/administrator/SchoolYearReportsPage').then((m) => ({
default: () => <m.SchoolYearReportsPage reportType="balance-transfers" />,
})),
)
const NavBuilderPage = lazy(() =>
import('./pages/NavBuilderPage').then((m) => ({ default: m.NavBuilderPage })),
)
@@ -274,6 +295,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 })),
)
@@ -468,6 +495,20 @@ const InventoryMovementFormPage = lazy(() =>
default: m.InventoryMovementFormPage,
})),
)
const InventoryDashboardPage = lazy(() =>
import('./pages/inventory/InventoryDashboardPage').then((m) => ({ default: m.InventoryDashboardPage })),
)
const InventoryLowStockPage = lazy(() =>
import('./pages/inventory/InventoryLowStockPage').then((m) => ({ default: m.InventoryLowStockPage })),
)
const InventoryReorderSuggestionsPage = lazy(() =>
import('./pages/inventory/InventoryReorderSuggestionsPage').then((m) => ({
default: m.InventoryReorderSuggestionsPage,
})),
)
const InventoryStockStatusPage = lazy(() =>
import('./pages/inventory/InventoryStockStatusPage').then((m) => ({ default: m.InventoryStockStatusPage })),
)
const TeacherBookDistributePage = lazy(() =>
import('./pages/inventory/TeacherBookDistributePage').then((m) => ({
default: m.TeacherBookDistributePage,
@@ -502,6 +543,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 +662,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 })),
)
@@ -671,7 +732,7 @@ const FamilyComposeEmailPage = lazy(() =>
})),
)
const FamilyLegacyImportPage = lazy(() =>
import('./pages/family/FamilyLegacyImportPage').then((m) => ({ default: m.FamilyLegacyImportPage })),
import('./pages/FamiliesImportLegacyPage').then((m) => ({ default: m.FamiliesImportLegacyPage })),
)
const EmailTemplatesIndexPage = lazy(() =>
import('./pages/emails').then((m) => ({ default: m.EmailTemplatesIndexPage })),
@@ -961,6 +1022,7 @@ export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<SchoolYearProvider>
<GlobalTableSorting />
<Suspense fallback={<RouteFallback />}>
<Routes>
@@ -1073,6 +1135,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 +1162,18 @@ 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="admin/school-years" element={<SchoolYearsManagementPage />} />
<Route path="admin/school-years/close" element={<SchoolYearsManagementPage />} />
<Route path="admin/school-years/summary" element={<SchoolYearDetailPage />} />
<Route
path="admin/school-years/reports/closing"
element={<SchoolYearClosingReportPage />}
/>
<Route
path="admin/school-years/reports/balance-transfers"
element={<SchoolYearBalanceTransfersReportPage />}
/>
<Route path="administrator/discounts" element={<DiscountListPage />} />
<Route path="administrator/discounts/create" element={<DiscountCreatePage />} />
<Route path="administrator/discounts/apply" element={<DiscountApplyVoucherPage />} />
@@ -1169,6 +1246,16 @@ export default function App() {
<Route path="grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
<Route path="grading/main" element={<GradingMainPage />} />
<Route path="grading" element={<GradingMainPage />} />
<Route path="administrator/inventory" element={<InventoryDashboardPage />} />
<Route path="administrator/inventory/dashboard" element={<InventoryDashboardPage />} />
<Route path="inventory" element={<InventoryDashboardPage />} />
<Route path="inventory/dashboard" element={<InventoryDashboardPage />} />
<Route path="administrator/inventory/low-stock" element={<InventoryLowStockPage />} />
<Route path="administrator/inventory/reorder-suggestions" element={<InventoryReorderSuggestionsPage />} />
<Route path="administrator/inventory/stock-status" element={<InventoryStockStatusPage />} />
<Route path="inventory/low-stock" element={<InventoryLowStockPage />} />
<Route path="inventory/reorder-suggestions" element={<InventoryReorderSuggestionsPage />} />
<Route path="inventory/stock-status" element={<InventoryStockStatusPage />} />
<Route path="administrator/inventory/book" element={<InventoryBookIndexPage />} />
<Route path="administrator/inventory/book/create" element={<InventoryBookFormPage />} />
<Route path="administrator/inventory/book/:itemId/edit" element={<InventoryBookFormPage />} />
@@ -1210,6 +1297,7 @@ export default function App() {
<Route path="notifications/active" element={<NotificationsActivePage />} />
<Route path="administrator/notifications/deleted" element={<NotificationsDeletedPage />} />
<Route path="notifications/deleted" element={<NotificationsDeletedPage />} />
<Route path="administrator/tuition-forecast" element={<FinancialReportSummaryPage />} />
<Route path="administrator/payment/manual-pay" element={<ManualPayPage />} />
<Route
path="administrator/payment/manual-payment"
@@ -1259,10 +1347,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 />} />
@@ -1472,6 +1566,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</SchoolYearProvider>
</BrowserRouter>
</AuthProvider>
)
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../../alrahma_sunday_school_api"
},
{
"path": "../.."
}
],
"settings": {}
}
+146
View File
@@ -0,0 +1,146 @@
import { apiFetch } from './http'
const BASE = '/api/v1/attendance/management'
type ApiEnvelope<T> = { status?: boolean; message?: string; data?: T }
function unwrap<T>(body: ApiEnvelope<T> | T): T {
if (body && typeof body === 'object' && 'data' in body) return (body as ApiEnvelope<T>).data as T
return body as T
}
export type AttendanceManagementSummary = {
present: number
absent: number
late: number
early_dismissal: number
not_reported: number
follow_up_required: number
badge_exceptions: number
late_slip_reprints: number
}
export type AttendanceManagementEvent = {
id: number
person_type: string
person_id: number | null
person_name: string | null
role_grade: string | null
badge_id: string | null
event_date: string
attendance_status: string
report_status: string
entry_time: string | null
exit_time: string | null
official_entry_time: string | null
official_exit_time: string | null
entry_method: string | null
exit_method: string | null
manual_reason: string | null
reason: string | null
scan_location: string | null
exit_location: string | null
absence_count: number
late_count: number
early_dismissal_count: number
badge_exception_count: number
combination_code: string | null
risk_level: string
follow_up_required: boolean | number
follow_up_completed: boolean | number
action_needed: string | null
final_decision: string | null
notes: string | null
}
export type AttendanceManagementDashboard = {
date: string
summary: AttendanceManagementSummary
filters: {
attendance_status: string[]
report_status: string[]
risk_level: string[]
person_type: string[]
}
rows: AttendanceManagementEvent[]
}
export type ManualEntryPayload = {
person_type?: string
person_id?: number
person_name?: string
role_grade?: string
badge_id?: string
entry_time?: string
manual_reason?: string
report_status?: string
reason?: string
notes?: string
}
export type ScanPayload = {
badge_scan?: string
badge_id?: string
scan_type?: 'entry' | 'exit'
scan_time?: string
location?: string
report_status?: string
authorized?: boolean
reason?: string
}
export async function fetchAttendanceManagementDashboard(params: Record<string, string> = {}) {
const qs = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v.trim()) qs.set(k, v.trim())
})
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return unwrap<AttendanceManagementDashboard>(await apiFetch(`${BASE}/dashboard${suffix}`))
}
export async function fetchBadgeScanningList(params: Record<string, string> = {}) {
return fetchAttendanceManagementDashboard({
...params,
entry_method: 'badge_scan',
})
}
export async function recordManualAttendanceEntry(payload: ManualEntryPayload) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/manual-entry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function recordAttendanceBadgeScan(payload: ScanPayload) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function completeAttendanceFollowUp(id: number, payload: { report_status?: string; final_decision?: string; notes?: string }) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/${id}/follow-up`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function reprintAttendanceLateSlip(id: number, payload: { reason?: string; slip_number?: string } = {}) {
return unwrap<{ reprint: Record<string, unknown> }>(
await apiFetch(`${BASE}/${id}/late-slip-reprint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
+124 -33
View File
@@ -1,65 +1,156 @@
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, 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}`)
applyStoredSchoolYearHeaders(headers)
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()
}
+5 -5
View File
@@ -216,7 +216,7 @@ export async function fetchBelowSixtyEmailEditor(searchParams: URLSearchParams):
subject?: string
html?: string
}> {
return apiFetch(`${BASE}/below-sixty/email-editor${q(searchParams)}`)
return apiFetch(`${BASE}/below-sixty/email${q(searchParams)}`)
}
export async function postBelowSixtyEmail(body: Record<string, unknown>): Promise<unknown> {
@@ -278,14 +278,14 @@ export async function postPlacementSection(body: Record<string, unknown>): Promi
}
export async function fetchPlacementBatch(batchId: string | number): Promise<unknown> {
return apiFetch(`${BASE}/placement-batch/${batchId}`)
return apiFetch(`${BASE}/placement/batch/${batchId}`)
}
export async function postPlacementBatch(
batchId: string | number,
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/placement-batch/${batchId}`, {
return apiFetch(`${BASE}/placement/batch/${batchId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -295,11 +295,11 @@ export async function postPlacementBatch(
export async function fetchScheduleMeetingForm(
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/schedule-meeting${q(searchParams)}`)
return apiFetch(`${BASE}/below-sixty/meeting${q(searchParams)}`)
}
export async function postScheduleMeeting(body: Record<string, unknown>): Promise<unknown> {
return apiFetch(`${BASE}/schedule-meeting`, {
return apiFetch(`${BASE}/below-sixty/meeting`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
+54 -4
View File
@@ -1,4 +1,5 @@
import { apiUrl } from '../lib/apiOrigin'
import { selectedSchoolYearHeaders, withSelectedSchoolYearUrl } from '../lib/schoolYearSelection'
const TOKEN_KEY = 'alrahma_api_token'
@@ -11,6 +12,37 @@ 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 function applyStoredSchoolYearHeaders(headers: Headers): Headers {
for (const [key, value] of Object.entries(selectedSchoolYearHeaders())) {
if (!headers.has(key)) headers.set(key, value)
}
return headers
}
export function withStoredSchoolYearUrl(url: string): string {
return withSelectedSchoolYearUrl(url)
}
export class ApiHttpError extends Error {
readonly status: number
readonly body: unknown
@@ -30,24 +62,41 @@ 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)
if (attachAuth && token) {
applyStoredSchoolYearHeaders(headers)
}
const method = String(init.method ?? 'GET').toUpperCase()
const url = apiUrl(method === 'GET' ? withStoredSchoolYearUrl(path) : 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_URL 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 +104,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)
}
+78 -12
View File
@@ -4,13 +4,20 @@
*/
import { apiFetch } from './http'
const BASE = '/api/v1/administrator/inventory'
const BASE = '/api/v1/inventory'
function q(searchParams: URLSearchParams): string {
const s = searchParams.toString()
return s ? `?${s}` : ''
}
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type InventoryKind = 'book' | 'classroom' | 'kitchen' | 'office'
/** Index list: expect `{ items, categories, userNames }` (names optional). */
@@ -18,11 +25,13 @@ 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 unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`))
}
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
return apiFetch(`${BASE}/category`, {
return apiFetch(`${BASE}/categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -35,7 +44,7 @@ export async function fetchInventoryItem(itemId: string | number): Promise<{
conditionOptions?: Record<string, string>
[key: string]: unknown
}> {
return apiFetch(`${BASE}/items/${itemId}`)
return unwrapData(await apiFetch(`${BASE}/items/${itemId}`))
}
/** Create form payload (categories, defaults). */
@@ -43,7 +52,9 @@ export async function fetchInventoryCreateForm(
kind: InventoryKind,
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/${kind}/create${q(searchParams)}`)
const params = new URLSearchParams(searchParams)
params.set('type', kind)
return unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`))
}
export async function postInventoryItem(body: Record<string, unknown>): Promise<unknown> {
@@ -59,7 +70,7 @@ export async function putInventoryItem(
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/items/${itemId}`, {
method: 'PUT',
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
@@ -84,7 +95,7 @@ export async function postClassroomAudit(
itemId: string | number,
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/items/${itemId}/classroom-audit`, {
return apiFetch(`${BASE}/items/${itemId}/audit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -92,11 +103,15 @@ 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 unwrapData(await apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`))
}
export async function fetchMovements(searchParams: URLSearchParams): Promise<unknown> {
return apiFetch(`${BASE}/movements${q(searchParams)}`)
return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`))
}
export async function fetchMovementForm(
@@ -104,9 +119,9 @@ export async function fetchMovementForm(
searchParams: URLSearchParams,
): Promise<unknown> {
if (movementId === undefined || movementId === '') {
return apiFetch(`${BASE}/movements/create${q(searchParams)}`)
return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`))
}
return apiFetch(`${BASE}/movements/${movementId}/edit${q(searchParams)}`)
return unwrapData(await apiFetch(`${BASE}/movements/${movementId}/edit${q(searchParams)}`))
}
export async function postMovement(body: Record<string, unknown>): Promise<unknown> {
@@ -122,7 +137,7 @@ export async function putMovement(
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/movements/${movementId}`, {
method: 'PUT',
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
@@ -156,3 +171,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 unwrapData(await apiFetch(`${BASE}/low-stock`))
}
/** Fetch reorder suggestions for low-stock items. */
export async function fetchReorderSuggestions(): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/reorder-suggestions`))
}
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
export async function fetchStockStatus(
searchParams: URLSearchParams,
): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/stock-status${q(searchParams)}`))
}
/** Fetch inventory dashboard summary counts. */
export async function fetchInventoryDashboard(
searchParams: URLSearchParams,
): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/dashboard${q(searchParams)}`))
}
/** Void a movement (append-only correction with reverse entry). */
export async function voidMovement(
movementId: string | number,
reason: string,
): Promise<unknown> {
return apiFetch(`${BASE}/movements/${movementId}/void`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
})
}
/** Create a correction movement linked to an existing movement. */
export async function correctMovement(
movementId: string | number,
qtyChange: number,
reason: string,
): Promise<unknown> {
return apiFetch(`${BASE}/movements/${movementId}/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qty_change: qtyChange, reason }),
})
}
+8 -9
View File
@@ -2,9 +2,9 @@
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
*/
import { apiUrl } from '../lib/apiOrigin'
import { apiFetch, getStoredToken } from './http'
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
const BASE = '/api/v1/administrator/invoices'
const BASE = '/api/v1/finance/invoices'
function q(searchParams: URLSearchParams): string {
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 }),
@@ -50,12 +50,11 @@ export async function fetchInvoicePreview(invoiceId: string | number): Promise<u
/** Binary PDF with JWT (for tabs that cannot send Authorization). */
export async function fetchInvoicePdfBlob(invoiceId: string | number): Promise<Blob> {
const token = getStoredToken()
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/pdf',
},
})
const headers = new Headers({ Accept: 'application/pdf' })
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), { headers })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `PDF HTTP ${res.status}`)
+70
View File
@@ -0,0 +1,70 @@
import { apiFetch } from './http'
export type ParentProfileSummary = {
id: number
firstname?: string | null
lastname?: string | null
email?: string | null
cellphone?: string | null
is_active?: boolean
families_count?: number
selected_year_students_count?: number
balance?: number
positive_unpaid_balance?: number
credit_balance?: number
primary_family?: { id?: number; household_name?: string | null } | null
}
export type ParentProfileDetail = {
school_year: string
read_only: boolean
parent: Record<string, unknown> | null
families: Record<string, unknown>[]
students: Record<string, unknown>[]
emergency_contacts: Record<string, unknown>[]
invoices: Record<string, unknown>[]
payments: Record<string, unknown>[]
finance_summary: Record<string, unknown>
}
type Envelope<T> = { data?: T }
function unwrap<T>(response: T | Envelope<T>): T {
if (response && typeof response === 'object' && 'data' in response) {
return (response as Envelope<T>).data as T
}
return response as T
}
export async function fetchParentProfiles(params: {
schoolYear: string
q?: string
page?: number
perPage?: number
}): Promise<{
school_year: string
read_only: boolean
parents: ParentProfileSummary[]
pagination: { page: number; per_page: number; total: number }
}> {
const query = new URLSearchParams()
query.set('school_year', params.schoolYear)
if (params.q?.trim()) query.set('q', params.q.trim())
if (params.page) query.set('page', String(params.page))
if (params.perPage) query.set('per_page', String(params.perPage))
return unwrap(await apiFetch(`/api/v1/parent-profiles?${query.toString()}`))
}
export async function fetchParentProfile(parentId: number, schoolYear: string): Promise<ParentProfileDetail> {
const query = new URLSearchParams({ school_year: schoolYear })
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`))
}
export async function updateParentProfile(parentId: number, schoolYear: string, payload: Record<string, unknown>): Promise<ParentProfileDetail> {
const query = new URLSearchParams({ school_year: schoolYear })
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, school_year: schoolYear }),
}))
}
+61 -6
View File
@@ -3,7 +3,7 @@
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type AdminNotificationRow = {
id?: number
@@ -56,6 +56,7 @@ async function postMultipart<T>(path: string, formData: FormData): Promise<T> {
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
@@ -136,6 +137,7 @@ export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string
const headers = new Headers({ Accept: '*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(apiPath), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -175,7 +177,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 +299,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 +318,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,10 +333,11 @@ 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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -341,22 +366,52 @@ 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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const raw = await res.blob()
+13 -4
View File
@@ -2,10 +2,17 @@
* Teacher / admin print copy requests (legacy CI: print-requests/*).
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type PrintRequestRow = Record<string, unknown>
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type TeacherPrintRequestsContext = {
sundays?: string[]
times?: string[]
@@ -23,6 +30,7 @@ async function multipartRequest<T>(path: string, formData: FormData, method = 'P
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method, headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
@@ -60,15 +68,15 @@ export async function deletePrintRequest(requestId: number | string): Promise<{
}
export async function fetchAdminPrintRequests(): Promise<{ print_requests: PrintRequestRow[] }> {
return apiFetch('/api/v1/administrator/print-requests')
return unwrapData(await apiFetch('/api/v1/print-requests/admin'))
}
export async function adminUpdatePrintRequestStatus(
requestId: number | string,
payload: { status: string; admin_id?: number | string },
): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`/api/v1/administrator/print-requests/${encodeURIComponent(String(requestId))}/status`, {
method: 'POST',
return apiFetch(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
@@ -83,6 +91,7 @@ export async function openPrintRequestFile(requestId: number | string): Promise<
const token = getStoredToken()
const headers = new Headers({ Accept: '*/*' })
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(printRequestFileUrl(requestId)), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
+103 -23
View File
@@ -2,7 +2,7 @@
* Stickers, badges, report cards (legacy CI printables_reports/*).
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type ClassOption = { class_section_id?: number; id?: number; class_section_name?: string }
@@ -25,22 +25,31 @@ export async function fetchStickerFormOptions(params?: {
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`/api/v1/administrator/printables/stickers/form-options${suffix}`)
const body = await apiFetch<{
ok?: boolean
data?: { classes?: ClassOption[]; students?: StudentOption[] }
classes?: ClassOption[]
students?: StudentOption[]
}>(`/api/v1/reports/stickers/form-data${suffix}`)
return body.data ?? body
}
export async function fetchStudentsByClass(classId: number | string): Promise<StudentOption[]> {
const body = await apiFetch<{ students?: StudentOption[] } | StudentOption[]>(
`/api/v1/administrator/students/by-class/${encodeURIComponent(String(classId))}`,
)
if (Array.isArray(body)) return body
return body.students ?? []
const qs = new URLSearchParams({ class_section_id: String(classId) })
const body = await apiFetch<{
ok?: boolean
data?: { students?: StudentOption[] }
students?: StudentOption[]
}>(`/api/v1/reports/stickers/students?${qs}`)
return body.data?.students ?? body.students ?? []
}
export async function postStickerGenerate(formData: FormData): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiUrl('/api/v1/administrator/printables/stickers/generate'), {
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl('/api/v1/reports/stickers/print'), {
method: 'POST',
headers,
body: formData,
@@ -61,6 +70,7 @@ export async function postBadgeGenerate(formData: FormData): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl('/api/v1/badges/pdf'), {
method: 'POST',
headers,
@@ -89,7 +99,7 @@ export async function fetchStickerPreviewCounts(classId?: number | string | null
const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<
StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts }
>(`/api/v1/administrator/printables/stickers/preview${suffix}`)
>(`/api/v1/reports/stickers/preview${suffix}`)
if (body.data) return body.data
return body
}
@@ -170,7 +180,8 @@ export async function fetchReportCardMeta(params?: {
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`/api/v1/administrator/printables/report-card/meta${suffix}`)
const body = await apiFetch<ReportCardMeta & { data?: ReportCardMeta }>(`/api/v1/administrator/printables/report-card/meta${suffix}`)
return body.data ?? body
}
export async function fetchReportCardAck(params: {
@@ -182,9 +193,11 @@ export async function fetchReportCardAck(params: {
qs.set('student_id', String(params.student_id))
if (params.school_year) qs.set('school_year', params.school_year)
if (params.semester) qs.set('semester', params.semester ?? '')
return apiFetch(`/api/v1/administrator/printables/report-card/ack?${qs}`)
const body = await apiFetch<{ data?: { viewed_at?: string; signed_at?: string; signed_name?: string }; viewed_at?: string; signed_at?: string; signed_name?: string }>(`/api/v1/administrator/printables/report-card/ack?${qs}`)
return body.data ?? body
}
export async function fetchReportCardCompleteness(params: {
class_section_id: number | string
school_year?: string
@@ -199,11 +212,17 @@ export async function fetchReportCardCompleteness(params: {
qs.set('class_section_id', String(params.class_section_id))
if (params.school_year) qs.set('school_year', params.school_year ?? '')
if (params.semester) qs.set('semester', params.semester ?? '')
return apiFetch(`/api/v1/administrator/printables/report-card/completeness?${qs}`)
const body = await apiFetch<{
data?: { students?: Array<Record<string, unknown>>; summary?: Record<string, unknown>; used_fallback?: boolean }
students?: Array<Record<string, unknown>>
summary?: Record<string, unknown>
used_fallback?: boolean
}>(`/api/v1/administrator/printables/report-card/completeness?${qs}`)
return body.data ?? body
}
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
export function reportCardStudentUrl(
function reportCardStudentPath(
studentId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
@@ -213,10 +232,10 @@ export function reportCardStudentUrl(
if (opts.download) qs.set('download', '1')
if (opts.report_date) qs.set('report_date', opts.report_date)
qs.set('cb', String(Date.now()))
return apiUrl(`/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`)
return `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
}
export function reportCardClassUrl(
function reportCardClassPath(
classSectionId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
@@ -226,7 +245,22 @@ export function reportCardClassUrl(
if (opts.download) qs.set('download', '1')
if (opts.report_date) qs.set('report_date', opts.report_date)
qs.set('cb', String(Date.now()))
return apiUrl(`/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`)
return `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
}
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
export function reportCardStudentUrl(
studentId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
return apiUrl(reportCardStudentPath(studentId, opts))
}
export function reportCardClassUrl(
classSectionId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
return apiUrl(reportCardClassPath(classSectionId, opts))
}
/** HTML document for iframe preview (Bearer auth; unlike raw iframe src). */
@@ -242,6 +276,7 @@ export async function fetchReportCardStudentHtml(
const headers = new Headers({ Accept: 'text/html' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const path = `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
@@ -260,20 +295,65 @@ export async function fetchReportCardClassHtml(
const headers = new Headers({ Accept: 'text/html' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const path = `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
return res.text()
}
/** Opens PDF download in new tab (authenticated GET). */
export async function openReportCardDownload(url: string): Promise<void> {
async function fetchReportCardPdf(path: string): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (!token) {
throw new ApiHttpError('Your session has expired. Please sign in again.', 401, null)
}
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) {
const errBody: unknown = await res.json().catch(() => null)
const msg =
typeof errBody === 'object' && errBody !== null && 'message' in errBody
? String((errBody as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody)
}
const raw = await res.blob()
const blob = new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
}
function openBlob(blob: Blob): void {
const objectUrl = URL.createObjectURL(blob)
window.open(objectUrl, '_blank', 'noopener')
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
}
/** Opens PDF download in new tab (authenticated GET). */
export async function openReportCardStudentDownload(
studentId: number | string,
opts: { school_year: string; semester?: string; report_date?: string },
): Promise<void> {
openBlob(
await fetchReportCardPdf(
reportCardStudentPath(studentId, {
...opts,
download: true,
}),
),
)
}
export async function openReportCardClassDownload(
classSectionId: number | string,
opts: { school_year: string; semester?: string; report_date?: string },
): Promise<void> {
openBlob(
await fetchReportCardPdf(
reportCardClassPath(classSectionId, {
...opts,
download: true,
}),
),
)
}
+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)
}
+12 -7
View File
@@ -3,7 +3,7 @@
* Backend is expected under `/api/v1/administrator/reimbursements`.
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
const BASE = '/api/v1/administrator/reimbursements'
@@ -58,6 +58,7 @@ export async function downloadReimbursementsExport(params?: {
const headers = new Headers({ Accept: 'text/csv,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -66,10 +67,11 @@ export async function downloadReimbursementsExport(params?: {
export async function downloadBatchCsv(batchId: number | string): Promise<void> {
const qs = new URLSearchParams({ batch_id: String(batchId) })
const url = apiUrl(`${BASE}/batch/export?${qs}`)
const url = apiUrl(`${BASE}/batches/export?${qs}`)
const headers = new Headers({ Accept: 'text/csv,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
@@ -80,7 +82,8 @@ export async function postBatchEmail(formData: FormData): Promise<{ success?: bo
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiUrl(`${BASE}/batch/send`), { method: 'POST', headers, body: formData })
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`${BASE}/batches/email`), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
const msg =
@@ -111,13 +114,14 @@ export async function createReimbursementBatch(): Promise<{
csrf_hash?: string
}> {
const fd = new FormData()
return postFormExpectJson(`${BASE}/batch/create`, fd)
return postFormExpectJson(`${BASE}/batches`, fd)
}
async function postFormExpectJson(path: string, form: FormData): Promise<{ success?: boolean; error?: string; csrf_hash?: string; reimbursement_id?: number } & Record<string, unknown>> {
const headers = new Headers({ Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: form })
const body: unknown = await res.json().catch(() => ({}))
const data = body as Record<string, unknown>
@@ -131,15 +135,15 @@ async function postFormExpectJson(path: string, form: FormData): Promise<{ succe
}
export function postBatchUpdate(form: FormData) {
return postFormExpectJson(`${BASE}/batch/update`, form)
return postFormExpectJson(`${BASE}/batches/assign`, form)
}
export function postBatchLock(form: FormData) {
return postFormExpectJson(`${BASE}/batch/lock`, form)
return postFormExpectJson(`${BASE}/batches/lock`, form)
}
export function postAdminCheckUpload(form: FormData) {
return postFormExpectJson(`${BASE}/batch/admin-file/upload`, form)
return postFormExpectJson(`${BASE}/batches/admin-files`, form)
}
export function postMarkDonation(form: FormData) {
@@ -165,6 +169,7 @@ async function postMultipart(path: string, formData: FormData): Promise<{ ok?: b
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
+459
View File
@@ -0,0 +1,459 @@
import { apiFetch } from './http'
export type SchoolYearStatus = 'draft' | 'active' | 'closed' | 'archived' | string
export type SchoolYearRecord = {
id: number
name: string
start_date: string
end_date: string
status: SchoolYearStatus
is_current: boolean
isCurrent?: boolean
closed_at?: string | null
closed_by?: number | null
}
export type SchoolYearSummary = {
school_year: SchoolYearRecord
totals: {
students_considered: number
students_blocked: number
parents_with_balances: number
parents_with_positive_balance?: number
net_balance_to_transfer: number
net_balance_impact?: number
total_positive_unpaid_balance?: number
total_credit_balance?: number
}
}
export type SchoolYearPromotionRow = {
student_id: number
student_name: string
parent_id?: number | null
current_grade?: string | null
current_class?: string | null
promotion_action: string
target_grade?: string | null
target_class?: string | null
target_class_section_id?: number | null
warnings?: string[]
decision?: string | null
score?: number | null
}
export type SchoolYearPromotionPreview = {
rows: SchoolYearPromotionRow[]
summary: {
total_students: number
promote: number
repeat: number
graduate: number
transfer: number
withdraw: number
hold: number
}
}
export type SchoolYearParentBalanceRow = {
parent_id: number
parent_name?: string | null
student_names?: string[]
old_unpaid_balance: number
positive_unpaid_balance?: number
credit_balance: number
net_balance?: number
amount_to_transfer: number
positive_invoice_ids?: number[]
credit_invoice_ids?: number[]
source_invoice_ids?: number[]
existing_transfer_conflict?: boolean
}
export type SchoolYearParentBalancePreview = {
from_school_year?: string
to_school_year?: string
rows: SchoolYearParentBalanceRow[]
summary: {
parents_with_non_zero_balance: number
parents_with_positive_balance?: number
parents_with_credit_balance: number
total_old_unpaid_balance: number
total_positive_unpaid_balance?: number
total_credit_balance: number
net_balance_to_transfer: number
net_balance_impact?: number
existing_transfer_conflicts: number
}
}
export type SchoolYearCloseValidation = {
can_close: boolean
errors: string[]
warnings: string[]
promotion_summary: SchoolYearPromotionPreview['summary']
financial_summary: SchoolYearParentBalancePreview['summary']
}
export type SchoolYearClosePreview = {
school_year: SchoolYearRecord
validation: SchoolYearCloseValidation
promotion_preview: SchoolYearPromotionPreview
parent_balances: SchoolYearParentBalancePreview
}
export type SchoolYearCloseResult = {
closed_school_year: SchoolYearRecord
new_school_year: SchoolYearRecord
promotion_counts?: Record<string, number>
balance_counts?: Record<string, number>
}
export type SchoolYearClosingReport = {
old_school_year?: SchoolYearRecord | null
new_school_year?: SchoolYearRecord | null
closed_by?: string | number | null
closed_at?: string | null
audit_reference?: string | null
totals?: {
students_promoted?: number
students_repeated?: number
students_graduated?: number
students_transferred?: number
students_withdrawn?: number
parents_with_unpaid_balances?: number
total_unpaid_balance_transferred?: number
parents_with_credit_balances?: number
total_credit_carried_or_reported?: number
total_credit_transferred?: number
net_balance_impact?: number
}
warnings?: string[]
}
export type SchoolYearBalanceTransferReportRow = {
parent_id: number
parent_name?: string | null
old_school_year?: string | null
new_school_year?: string | null
source_invoice_ids?: Array<number | string>
old_unpaid_amount?: number
new_old_balance_invoice_id?: number | string | null
transfer_status?: string | null
transfer_date?: string | null
created_by?: string | number | null
}
export type SchoolYearBalanceTransferReport = {
school_year?: SchoolYearRecord | null
rows: SchoolYearBalanceTransferReportRow[]
summary?: {
parent_count?: number
total_transferred?: number
total_credit?: number
}
}
export type ParentStatementLine = {
label: string
amount: number
source_school_year?: string | null
invoice_id?: number | string | null
}
export type ParentStatement = {
parent_id?: number | null
parent_name?: string | null
school_year?: SchoolYearRecord | string | null
lines: ParentStatementLine[]
opening_balance?: number
current_balance?: number
}
export type SaveSchoolYearPayload = {
name: string
start_date: string
end_date: string
}
export type CloseSchoolYearPayload = {
new_school_year: SaveSchoolYearPayload
promotion_rules?: {
default_action?: string
exceptions?: Array<{
student_id: number
action: string
target_grade_id?: number | null
target_class_section_id?: number | null
}>
}
transfer_unpaid_balances?: boolean
confirmation?: string
}
type ApiListResponse = {
ok?: boolean
data?: SchoolYearRecord[]
}
type ApiRecordResponse = {
ok?: boolean
data?: SchoolYearRecord
}
type ApiSummaryResponse = {
ok?: boolean
data?: SchoolYearSummary
}
type ApiValidationResponse = {
ok?: boolean
validation?: SchoolYearCloseValidation
}
type ApiPreviewResponse = {
ok?: boolean
data?: SchoolYearClosePreview
}
type ApiParentBalancesResponse = {
ok?: boolean
data?: SchoolYearParentBalancePreview
}
type ApiPromotionPreviewResponse = {
ok?: boolean
data?: SchoolYearPromotionPreview
}
type ApiCloseResponse = {
ok?: boolean
data?: SchoolYearCloseResult
}
type ApiClosingReportResponse = {
ok?: boolean
data?: SchoolYearClosingReport
}
type ApiBalanceTransferReportResponse = {
ok?: boolean
data?: SchoolYearBalanceTransferReport
}
type ApiParentStatementResponse = {
ok?: boolean
data?: ParentStatement
}
export async function fetchSchoolYears(): Promise<SchoolYearRecord[]> {
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
return response.data ?? []
}
export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>('/api/v1/school-years', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.data) {
throw new Error('School year creation returned no record.')
}
return response.data
}
export async function updateSchoolYear(
schoolYear: string,
payload: SaveSchoolYearPayload,
): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(
`/api/v1/school-years/selected${selectedYearQuery(schoolYear)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.data) {
throw new Error('School year update returned no record.')
}
return response.data
}
function selectedYearQuery(schoolYear: string, extras?: Record<string, string | undefined>): string {
const query = new URLSearchParams()
query.set('school_year', schoolYear)
for (const [key, value] of Object.entries(extras ?? {})) {
if (value) query.set(key, value)
}
return `?${query.toString()}`
}
function selectedYearBody(payload: object, schoolYear: string): string {
return JSON.stringify({ ...payload, school_year: schoolYear })
}
export async function fetchSchoolYearSummary(schoolYear: string): Promise<SchoolYearSummary> {
const response = await apiFetch<ApiSummaryResponse>(
`/api/v1/school-years/summary${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year summary returned no data.')
}
return response.data
}
export async function validateSchoolYearClose(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseValidation> {
const response = await apiFetch<ApiValidationResponse>(
`/api/v1/school-years/validate-close${selectedYearQuery(schoolYear)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.validation) {
throw new Error('School year validation returned no payload.')
}
return response.validation
}
export async function previewSchoolYearClose(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearClosePreview> {
const response = await apiFetch<ApiPreviewResponse>(
`/api/v1/school-years/preview-close${selectedYearQuery(schoolYear)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.data) {
throw new Error('School year close preview returned no payload.')
}
return response.data
}
export async function closeSchoolYear(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseResult> {
const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/close${selectedYearQuery(schoolYear)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
})
if (!response.data) {
throw new Error('School year close returned no payload.')
}
return response.data
}
export async function reopenSchoolYear(schoolYear: string): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/reopen${selectedYearQuery(schoolYear)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ school_year: schoolYear }),
})
if (!response.data) {
throw new Error('School year reopen returned no payload.')
}
return response.data
}
export async function fetchSchoolYearParentBalances(
schoolYear: string,
toSchoolYear?: string,
): Promise<SchoolYearParentBalancePreview> {
const response = await apiFetch<ApiParentBalancesResponse>(
`/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
)
if (!response.data) {
throw new Error('Parent balances preview returned no payload.')
}
return response.data
}
export async function fetchSchoolYearPromotionPreview(
schoolYear: string,
toSchoolYear?: string,
): Promise<SchoolYearPromotionPreview> {
const response = await apiFetch<ApiPromotionPreviewResponse>(
`/api/v1/school-years/promotion-preview${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
)
if (!response.data) {
throw new Error('Promotion preview returned no payload.')
}
return response.data
}
export async function fetchSchoolYearClosingReport(
schoolYear: string,
): Promise<SchoolYearClosingReport> {
const response = await apiFetch<ApiClosingReportResponse>(
`/api/v1/school-years/reports/closing${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year closing report returned no payload.')
}
return response.data
}
export async function fetchSchoolYearBalanceTransferReport(
schoolYear: string,
): Promise<SchoolYearBalanceTransferReport> {
const response = await apiFetch<ApiBalanceTransferReportResponse>(
`/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year balance transfer report returned no payload.')
}
return response.data
}
export async function fetchParentStatement(params: {
schoolYear?: string | null
parentId?: number | null
}): Promise<ParentStatement> {
const query = new URLSearchParams()
if (params.schoolYear) query.set('school_year', params.schoolYear)
if (params.parentId != null) query.set('parent_id', String(params.parentId))
const response = await apiFetch<ApiParentStatementResponse>(
`/api/v1/parents/statements${query.toString() ? `?${query}` : ''}`,
)
if (!response.data) {
throw new Error('Parent statement returned no payload.')
}
return response.data
}
+397 -36
View File
@@ -1,5 +1,5 @@
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, getStoredToken } from './http'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
import type {
AdministratorAbsenceFormResponse,
AdministratorAbsenceSubmitResponse,
@@ -23,7 +23,6 @@ import type {
GradingOverviewResponse,
IpBansResponse,
LandingPageResponse,
LoginFailure,
LoginResponse,
LoginSuccess,
LateSlipLogsResponse,
@@ -33,6 +32,7 @@ import type {
NavMenuResponse,
ParentAttendanceResponse,
ParentAttendanceReportFormResponse,
ParentEnrollmentActionResponse,
ParentEnrollmentsResponse,
ParentEventsOverviewResponse,
ParentInvoicesResponse,
@@ -60,6 +60,13 @@ import type {
ParentProfileRecord,
ContactSubmitResponse,
CompetitionScoresEditResponse,
CompetitionWinnerAdminActionResponse,
CompetitionWinnerAdminFormResponse,
CompetitionWinnerAdminIndexResponse,
CompetitionWinnerAdminPreviewResponse,
CompetitionWinnerAdminScoresResponse,
CompetitionWinnerAdminWinnersResponse,
CompetitionWinnerExportQuizResponse,
CompetitionScoresIndexResponse,
ExamDraftAdminResponse,
PublicCompetitionIndexResponse,
@@ -89,36 +96,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 +496,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 +508,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(),
@@ -480,6 +596,7 @@ export async function uploadEarlyDismissalSignature(formData: FormData): Promise
const token = getStoredToken()
const headers = new Headers({ Accept: 'application/json' })
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl('/api/v1/attendance/early-dismissals/signature'), {
method: 'POST',
body: formData,
@@ -884,6 +1001,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 +1121,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 +1132,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',
@@ -1015,6 +1226,7 @@ export async function openProtectedApiFile(path: string): Promise<void> {
const headers = new Headers()
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) {
@@ -1038,12 +1250,17 @@ 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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), {
method: 'POST',
method: init.method ?? 'POST',
headers,
body: formData,
})
@@ -1192,6 +1409,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>
@@ -1443,10 +1722,15 @@ export async function sendFamilyComposeEmail(payload: {
html: string
return_url?: string
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`/api/v1/family-admin/compose-email/send`, {
return apiFetch(`/api/v1/family-admin/compose-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify({
recipient: payload.to.trim(),
subject: payload.subject.trim(),
html_message: payload.html,
return_url: payload.return_url,
}),
})
}
@@ -1506,17 +1790,18 @@ export async function fetchPaypalTransactions(params?: {
if (params?.q) query.set('q', params.q)
if (params?.perPage) query.set('per_page', String(params.perPage))
const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<PaypalTransactionsResponse>(`/api/v1/paypal-transactions${qs}`)
return apiFetch<PaypalTransactionsResponse>(`/api/v1/administrator/paypal-transactions${qs}`)
}
export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promise<Blob> {
const headers = new Headers({ Accept: 'text/csv' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const query = new URLSearchParams()
if (params?.q) query.set('q', params.q)
const suffix = query.size > 0 ? `?${query.toString()}` : ''
const res = await fetch(apiUrl(`/api/v1/paypal-transactions/csv${suffix}`), { headers })
const res = await fetch(apiUrl(`/api/v1/administrator/paypal-transactions/csv${suffix}`), { headers })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.blob()
}
@@ -1579,6 +1864,17 @@ export async function fetchParentEnrollments(
return apiFetch<ParentEnrollmentsResponse>(`/api/v1/parents/enrollments${q}`)
}
export async function updateParentEnrollments(payload: {
enroll: number[]
withdraw: number[]
}): Promise<ParentEnrollmentActionResponse> {
return apiFetch<ParentEnrollmentActionResponse>('/api/v1/parents/enrollments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function fetchParentInvoices(
schoolYear: string | null,
): Promise<ParentInvoicesResponse> {
@@ -1678,6 +1974,8 @@ export async function fetchClassProgressGroups(params?: {
weekStart?: string | null
weekEnd?: string | null
subject?: string | null
schoolYear?: string | null
semester?: string | null
page?: number
perPage?: number
}): Promise<ClassProgressGroupsResponse> {
@@ -1688,6 +1986,8 @@ export async function fetchClassProgressGroups(params?: {
if (params?.weekStart) query.set('week_start', params.weekStart)
if (params?.weekEnd) query.set('week_end', params.weekEnd)
if (params?.subject) query.set('subject', params.subject)
if (params?.schoolYear) query.set('school_year', params.schoolYear)
if (params?.semester) query.set('semester', params.semester)
if (params?.page) query.set('page', String(params.page))
if (params?.perPage) query.set('per_page', String(params.perPage))
return apiFetch<ClassProgressGroupsResponse>(`/api/v1/class-progress?${query.toString()}`)
@@ -1700,10 +2000,14 @@ export async function fetchClassProgressDetail(id: number): Promise<ClassProgres
export async function fetchClassProgressMeta(params?: {
classId?: number | null
sundayCount?: number | null
schoolYear?: string | null
semester?: string | null
}): Promise<ClassProgressMetaResponse> {
const query = new URLSearchParams()
if (params?.classId) query.set('class_id', String(params.classId))
if (params?.sundayCount) query.set('sunday_count', String(params.sundayCount))
if (params?.schoolYear) query.set('school_year', params.schoolYear)
if (params?.semester) query.set('semester', params.semester)
const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<ClassProgressMetaResponse>(`/api/v1/class-progress/meta${qs}`)
}
@@ -1867,6 +2171,7 @@ export async function fetchReportCardPdf(studentId: number): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`/api/v1/reports/report-cards/students/${studentId}`), {
headers,
})
@@ -2024,10 +2329,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 +2507,31 @@ export async function fetchExpensesList(params?: {
}
}
function normalizeExpenseFormOptions(
body: Partial<ExpenseCreateFormResponse> & {
retailors?: string[]
staff?: ExpenseUserOption[]
},
): ExpenseCreateFormResponse {
return {
retailors: body.retailors ?? [],
users: body.users ?? body.staff ?? [],
}
}
export async function fetchExpenseCreateForm(): Promise<ExpenseCreateFormResponse> {
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 +2541,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 }),
})
}
+39 -11
View File
@@ -1,10 +1,16 @@
/**
* Staff directory (legacy CI `staff/*`).
* Expected Laravel routes under `/api/v1/administrator/staff`.
* Staff directory.
*/
import { apiFetch } from './http'
const BASE = '/api/v1/administrator/staff'
const BASE = '/api/v1/staff'
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type StaffListRow = {
id: number
@@ -17,6 +23,7 @@ export type StaffListRow = {
active_role?: string | null
class_section?: string | null
verification_issue?: boolean
status?: string | null
}
export type StaffIndexResponse = {
@@ -25,12 +32,16 @@ export type StaffIndexResponse = {
school_year?: string | null
semester?: string | null
schoolYears?: string[]
meta?: unknown
}
export type RoleOption = { id: number; name: string }
export type RoleOption = { id: number; name: string; label?: string }
export type StaffFormOptionsResponse = {
roles?: RoleOption[]
statuses?: Array<{ value: string; label: string }>
school_year?: string | null
is_read_only_year?: boolean
}
export type StaffMemberResponse = {
@@ -52,28 +63,45 @@ export type StaffMemberResponse = {
export async function fetchStaffIndex(params?: {
school_year?: string
semester?: string
role?: string
status?: string
search?: string
page?: number
per_page?: number
}): Promise<StaffIndexResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
if (params?.role) qs.set('role', params.role)
if (params?.status) qs.set('status', params.status)
if (params?.search) qs.set('search', params.search)
if (params?.page) qs.set('page', String(params.page))
if (params?.per_page) qs.set('per_page', String(params.per_page))
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`${BASE}${suffix}`)
return unwrapData(await apiFetch(`${BASE}${suffix}`))
}
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> {
return apiFetch(`${BASE}/form-options`)
export async function fetchStaffFormOptions(params?: { school_year?: string }): Promise<StaffFormOptionsResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
return unwrapData(await apiFetch(`${BASE}/form-options${qs.toString() ? `?${qs}` : ''}`))
}
export async function fetchStaffMember(id: number): Promise<StaffMemberResponse> {
return apiFetch(`${BASE}/${id}`)
export async function fetchStaffMember(id: number, params?: { school_year?: string }): Promise<StaffMemberResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
return unwrapData(await apiFetch(`${BASE}/${id}${qs.toString() ? `?${qs}` : ''}`))
}
export async function createStaff(payload: {
firstname: string
lastname: string
email: string
phone?: string
password: string
role_id: number
status?: string
school_year: string
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}`, {
method: 'POST',
@@ -84,10 +112,10 @@ export async function createStaff(payload: {
export async function updateStaff(
id: number,
payload: { firstname: string; lastname: string; email: string; phone?: string },
payload: { firstname: string; lastname: string; email: string; phone?: string; role_id?: number; status?: string; school_year: string },
): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/${id}`, {
method: 'PUT',
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
+2 -1
View File
@@ -2,7 +2,7 @@
* Teacher portal API (`Views/teacher/*` parity).
* Laravel should expose matching routes under `/api/v1/teacher/...`.
*/
import { apiFetch, getStoredToken } from './http'
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
import { apiUrl } from '../lib/apiOrigin'
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
@@ -47,6 +47,7 @@ export async function postTeacherMultipart<T>(path: string, formData: FormData):
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
+144
View File
@@ -0,0 +1,144 @@
import { apiFetch } from './http'
type TrophyApiEnvelope<T> = {
ok?: boolean
data?: T
}
export type TrophyStudent = {
student_id?: number
class_section_id?: number
name?: string
firstname?: string | null
lastname?: string | null
school_id?: string | null
gender?: string | null
fall_score?: number | null
spring_score?: number | null
year_score?: number | null
projected_trophy?: boolean
predicted?: boolean
actual?: boolean
status?: string
}
export type TrophyProjectionClassResult = {
section_id: number
section_name: string
students: TrophyStudent[]
threshold: number | null
trophy_count: number
student_count: number
scored_count: number
method: string
boys: number
girls: number
trophy_boys: number
trophy_girls: number
pct_boys: number
pct_girls: number
pct_trophy_boys: number
pct_trophy_girls: number
pct_trophy_total: number
}
export type TrophyProjectionPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyProjectionClassResult[]
summary: Record<string, number>
}
export type TrophyWinnersClassResult = {
section_id: number
section_name: string
threshold: number | null
winners: TrophyStudent[]
student_count: number
boys: number
girls: number
trophy_boys: number
trophy_girls: number
pct_boys: number
pct_girls: number
pct_trophy_boys: number
pct_trophy_girls: number
pct_trophy_total: number
}
export type TrophyWinnersPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyWinnersClassResult[]
summary: Record<string, number>
}
export type TrophyFinalClassResult = {
section_id: number
section_name: string
students: TrophyStudent[]
student_count: number
fall_threshold: number | null
year_threshold: number | null
predicted_count: number
actual_count: number
confirmed: number
surprises: number
missed: number
accuracy: number
}
export type TrophyGenderSummary = {
total: number
boys: number
girls: number
other: number
pct_boys: number
pct_girls: number
pct_other: number
}
export type TrophyFinalPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyFinalClassResult[]
summary: Record<string, number>
winner_gender_summary: TrophyGenderSummary
all_gender_summary: TrophyGenderSummary
pass_gender_summary: TrophyGenderSummary
winner_stickers: Array<{ name: string; section: string }>
}
type TrophyFilters = {
school_year?: string
percentile?: string | number
}
function withQuery(path: string, params?: TrophyFilters) {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', String(params.school_year))
if (params?.percentile !== undefined && String(params.percentile).trim() !== '') {
qs.set('percentile', String(params.percentile))
}
return qs.size > 0 ? `${path}?${qs.toString()}` : path
}
async function fetchTrophyPayload<T>(path: string, params?: TrophyFilters): Promise<T> {
const res = await apiFetch<TrophyApiEnvelope<T>>(withQuery(path, params))
return (res.data ?? {}) as T
}
export function fetchTrophyProjection(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyProjectionPayload>('/api/v1/administrator/trophy', params)
}
export function fetchTrophyWinners(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyWinnersPayload>('/api/v1/administrator/trophy/winners', params)
}
export function fetchTrophyFinal(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyFinalPayload>('/api/v1/administrator/trophy/final', params)
}
+145
View File
@@ -2,6 +2,7 @@ export type AuthUser = {
id: number
name: string
roles: Record<string, boolean>
permissions?: Record<string, boolean> | string[]
class_section_id?: number | null
class_section_name?: string | null
}
@@ -348,9 +349,23 @@ export type ParentProfileResponse = {
export type ParentInvoiceRow = {
id: number
invoice_number?: string | null
total_amount?: number
paid_amount?: number
balance?: number
status?: string | null
due_date?: string | null
last_paid_amount?: number
last_payment_date?: string | null
payments?: Array<{
id: number
invoice_id?: number
paid_amount?: number
balance?: number
payment_method?: string | null
payment_date?: string | null
transaction_id?: string | null
status?: string | null
}>
}
export type ParentInvoicesResponse = {
@@ -376,6 +391,19 @@ export type ParentEnrollmentsResponse = {
students: ParentEnrollmentStudent[]
schoolYears: { school_year: string }[]
selectedYear: string | null
withdrawalDeadline?: string | null
lastDayOfRegistration?: string | null
schoolStartDate?: string | null
}
export type ParentEnrollmentActionResponse = {
ok: true
enrolled: number[]
withdrawn: number[]
data?: {
enrolled?: number[]
withdrawn?: number[]
}
}
export type ContactSubmitResponse = {
@@ -639,6 +667,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 +1007,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 +1015,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 +1086,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
+55 -9
View File
@@ -6,6 +6,13 @@ import { apiFetch } from './http'
const BASE = '/api/v1/whatsapp'
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type WhatsappSectionRow = {
class_section_id?: number
id?: number
@@ -16,6 +23,9 @@ export type WhatsappSectionRow = {
}
export type WhatsappLinkRow = {
id?: number
class_section_id?: number
class_section_name?: string
invite_link?: string | null
active?: number | boolean
}
@@ -79,8 +89,29 @@ export async function fetchWhatsappManageLinks(params?: {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
if (!qs.has('per_page')) qs.set('per_page', '200')
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`${BASE}/links${suffix}`)
const body = await apiFetch<WhatsappManageLinksResponse & {
data?: WhatsappManageLinksResponse & { links?: WhatsappLinkRow[] }
links?: WhatsappLinkRow[]
class_sections?: ParentContactsByClassSection[]
}>(`${BASE}/links${suffix}`)
const data = unwrapData(body) as WhatsappManageLinksResponse & {
links?: WhatsappLinkRow[]
class_sections?: ParentContactsByClassSection[]
}
if (!data.sections && data.class_sections) data.sections = data.class_sections
if (!data.linksBySection && Array.isArray(data.links)) {
data.linksBySection = Object.fromEntries(
data.links
.filter((link) => link.class_section_id != null)
.map((link) => [String(link.class_section_id), link]),
)
}
return data
}
export async function saveWhatsappLink(payload: {
@@ -89,11 +120,11 @@ export async function saveWhatsappLink(payload: {
invite_link: string
active?: boolean
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/links`, {
return unwrapData(await apiFetch(`${BASE}/links`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}))
}
export async function sendWhatsappInvites(payload: {
@@ -101,11 +132,11 @@ export async function sendWhatsappInvites(payload: {
class_section_id?: number | null
parent_ids?: number[]
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/invites/send`, {
return unwrapData(await apiFetch(`${BASE}/invites/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}))
}
export async function fetchWhatsappParentContacts(params?: {
@@ -116,11 +147,25 @@ export async function fetchWhatsappParentContacts(params?: {
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`${BASE}/parent-contacts${suffix}`)
const body = await apiFetch<WhatsappParentContactsResponse & { data?: WhatsappParentContactsResponse }>(`${BASE}/parent-contacts${suffix}`)
return unwrapData(body)
}
export async function fetchWhatsappParentContactsByClass(): Promise<WhatsappParentContactsByClassResponse> {
return apiFetch(`${BASE}/parent-contacts-by-class`)
export async function fetchWhatsappParentContactsByClass(params?: {
school_year?: string
semester?: string
}): Promise<WhatsappParentContactsByClassResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<{
data?: { class_sections?: ParentContactsByClassSection[]; classSections?: ParentContactsByClassSection[] }
class_sections?: ParentContactsByClassSection[]
classSections?: ParentContactsByClassSection[]
}>(`${BASE}/parent-contacts-by-class${suffix}`)
const data = unwrapData(body)
return { classSections: data.classSections ?? data.class_sections ?? [] }
}
export async function updateWhatsappMembership(payload: {
@@ -132,9 +177,10 @@ export async function updateWhatsappMembership(payload: {
primary_member?: string
second_member?: string
}): Promise<{ status?: string; message?: string }> {
return apiFetch(`${BASE}/membership`, {
const body = await apiFetch<{ status?: boolean; message?: string; data?: unknown }>(`${BASE}/membership`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { status: body.status ? 'ok' : undefined, message: body.message }
}
+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
@@ -0,0 +1,23 @@
import { useSchoolYear } from '../../context/SchoolYearContext'
export function ReadOnlyBanner({ className = '' }: { className?: string }) {
const { selectedSchoolYear, isReadOnlyYear } = useSchoolYear()
if (!selectedSchoolYear || !isReadOnlyYear) return null
return (
<div className={`alert alert-warning d-flex align-items-start gap-2 mb-3 ${className}`.trim()} role="status">
<i className="bi bi-lock-fill mt-1" aria-hidden />
<div>
<div className="fw-semibold">
Viewing archived school year: {selectedSchoolYear.name}
</div>
<div className="small">
Read-only mode. Add, edit, delete, import, attendance, grading, invoice, payment,
promotion, assignment, upload, and year-bound communication actions should remain disabled.
The API still has to reject forbidden writes, because buttons are not security.
</div>
</div>
</div>
)
}
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function ReadOnlyGuard({
children,
fallback,
}: {
children: ReactNode
fallback?: ReactNode
}) {
const { isReadOnlyYear, selectedSchoolYear } = useSchoolYear()
if (!isReadOnlyYear) return <>{children}</>
return (
<div className="border rounded p-3 bg-light text-muted">
{fallback ?? `This action is disabled because ${selectedSchoolYear?.name ?? 'the selected year'} is read-only.`}
</div>
)
}
@@ -0,0 +1,52 @@
import { useId } from 'react'
import { useSchoolYear } from '../../context/SchoolYearContext'
import { isCurrentSchoolYear } from '../../lib/schoolYearSelection'
import { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
export function SchoolYearSelector({ compact = false }: { compact?: boolean }) {
const {
schoolYears,
selectedSchoolYear,
isLoadingSchoolYears,
schoolYearError,
setSelectedSchoolYearName,
} = useSchoolYear()
const selectId = useId()
if (schoolYears.length === 0 && !isLoadingSchoolYears) {
return schoolYearError ? (
<span className="badge text-bg-warning" title={schoolYearError}>
School years unavailable
</span>
) : null
}
return (
<div className={`school-year-selector d-flex align-items-center gap-2 ${compact ? 'school-year-selector--compact' : ''}`}>
<label className="form-label mb-0 small text-nowrap" htmlFor={selectId}>
School Year
</label>
<select
id={selectId}
className="form-select form-select-sm"
value={selectedSchoolYear?.name ?? ''}
disabled={isLoadingSchoolYears || schoolYears.length === 0}
onChange={(event) => {
setSelectedSchoolYearName(event.target.value || null)
}}
>
{isLoadingSchoolYears && schoolYears.length === 0 ? (
<option value="">Loading</option>
) : null}
{schoolYears.map((year) => (
<option key={year.name} value={year.name}>
{year.name}
{isCurrentSchoolYear(year) ? ' Current' : ''}
{year.status && !isCurrentSchoolYear(year) ? ` ${year.status}` : ''}
</option>
))}
</select>
{selectedSchoolYear ? <SchoolYearStatusBadge status={selectedSchoolYear.status} /> : null}
</div>
)
}
@@ -0,0 +1,31 @@
import type { SchoolYearRecord } from '../../api/schoolYears'
function statusBadgeVariant(status: string | null | undefined): string {
switch (String(status ?? '').toLowerCase()) {
case 'active':
return 'success'
case 'closed':
return 'danger'
case 'archived':
return 'dark'
case 'draft':
return 'warning'
default:
return 'secondary'
}
}
export function SchoolYearStatusBadge({
status,
className = '',
}: {
status: SchoolYearRecord['status'] | null | undefined
className?: string
}) {
const label = String(status ?? 'unknown').trim() || 'unknown'
return (
<span className={`badge text-bg-${statusBadgeVariant(status)} ${className}`.trim()}>
{label.toUpperCase()}
</span>
)
}
+4
View File
@@ -0,0 +1,4 @@
export { ReadOnlyBanner } from './ReadOnlyBanner'
export { ReadOnlyGuard } from './ReadOnlyGuard'
export { SchoolYearSelector } from './SchoolYearSelector'
export { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
+164
View File
@@ -0,0 +1,164 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { useSearchParams } from 'react-router-dom'
import { fetchSchoolYears, type SchoolYearRecord } from '../api/schoolYears'
import { useAuth } from '../auth/AuthProvider'
import {
clearSelectedSchoolYear,
getStoredSelectedSchoolYearName,
isCurrentSchoolYear,
isReadOnlySchoolYear,
storeSelectedSchoolYear,
} from '../lib/schoolYearSelection'
type SchoolYearContextValue = {
schoolYears: SchoolYearRecord[]
selectedSchoolYear: SchoolYearRecord | null
selectedSchoolYearName: string | null
activeSchoolYear: SchoolYearRecord | null
isLoadingSchoolYears: boolean
schoolYearError: string | null
isReadOnlyYear: boolean
setSelectedSchoolYearName: (name: string | null) => void
refreshSchoolYears: () => Promise<void>
}
const SchoolYearContext = createContext<SchoolYearContextValue | null>(null)
function yearNameFromSearchParams(searchParams: URLSearchParams): string | null {
return (
searchParams.get('school_year')?.trim() ||
searchParams.get('schoolYear')?.trim() ||
null
)
}
function resolveSelectedYear(params: {
years: SchoolYearRecord[]
searchParams: URLSearchParams
requestedName: string | null
}): SchoolYearRecord | null {
const { years, searchParams, requestedName } = params
if (years.length === 0) return null
const urlName = yearNameFromSearchParams(searchParams)
const storedName = getStoredSelectedSchoolYearName()
const byRequestedName = requestedName ? years.find((year) => year.name === requestedName) : null
if (byRequestedName) return byRequestedName
const byUrlName = urlName ? years.find((year) => year.name === urlName) : null
if (byUrlName) return byUrlName
const byStoredName = storedName ? years.find((year) => year.name === storedName) : null
if (byStoredName) return byStoredName
return years.find(isCurrentSchoolYear) ?? years[0] ?? null
}
export function SchoolYearProvider({ children }: { children: ReactNode }) {
const { token } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const [schoolYears, setSchoolYears] = useState<SchoolYearRecord[]>([])
const [requestedYearName, setRequestedYearName] = useState<string | null>(null)
const [isLoadingSchoolYears, setIsLoadingSchoolYears] = useState(false)
const [schoolYearError, setSchoolYearError] = useState<string | null>(null)
const refreshSchoolYears = useCallback(async () => {
if (!token) {
setSchoolYears([])
setSchoolYearError(null)
clearSelectedSchoolYear()
return
}
setIsLoadingSchoolYears(true)
setSchoolYearError(null)
try {
const years = await fetchSchoolYears()
setSchoolYears(years)
} catch (error) {
setSchoolYears([])
setSchoolYearError(error instanceof Error ? error.message : 'Unable to load school years.')
} finally {
setIsLoadingSchoolYears(false)
}
}, [token])
useEffect(() => {
void refreshSchoolYears()
}, [refreshSchoolYears])
const selectedSchoolYear = useMemo(
() =>
resolveSelectedYear({
years: schoolYears,
searchParams,
requestedName: requestedYearName,
}),
[requestedYearName, schoolYears, searchParams],
)
const activeSchoolYear = useMemo(
() => schoolYears.find(isCurrentSchoolYear) ?? null,
[schoolYears],
)
useEffect(() => {
storeSelectedSchoolYear(selectedSchoolYear)
if (!selectedSchoolYear || !token) return
setSearchParams((current) => {
const next = new URLSearchParams(current)
const currentName = next.get('school_year')
next.delete('school_year_id')
next.delete('year')
if (currentName === selectedSchoolYear.name && next.toString() === current.toString()) {
return current
}
next.set('school_year', selectedSchoolYear.name)
return next
}, { replace: true })
}, [selectedSchoolYear, setSearchParams, token])
const value = useMemo<SchoolYearContextValue>(() => ({
schoolYears,
selectedSchoolYear,
selectedSchoolYearName: selectedSchoolYear?.name ?? null,
activeSchoolYear,
isLoadingSchoolYears,
schoolYearError,
isReadOnlyYear: isReadOnlySchoolYear(selectedSchoolYear),
setSelectedSchoolYearName: setRequestedYearName,
refreshSchoolYears,
}), [
activeSchoolYear,
isLoadingSchoolYears,
refreshSchoolYears,
schoolYearError,
schoolYears,
selectedSchoolYear,
])
return <SchoolYearContext.Provider value={value}>{children}</SchoolYearContext.Provider>
}
export function useSchoolYear(): SchoolYearContextValue {
const context = useContext(SchoolYearContext)
if (!context) {
throw new Error('useSchoolYear must be used inside SchoolYearProvider.')
}
return context
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useMemo, useState } from 'react'
import { fetchSchoolYears, type SchoolYearRecord } from '../api/schoolYears'
import {
defaultSchoolYearOption,
mergeSchoolYearOptions,
type LegacySchoolYearValue,
} from '../lib/schoolYearOptions'
let cachedSchoolYears: SchoolYearRecord[] | null = null
let cachedSchoolYearsPromise: Promise<SchoolYearRecord[]> | null = null
async function loadCanonicalSchoolYears(): Promise<SchoolYearRecord[]> {
if (cachedSchoolYears) return cachedSchoolYears
if (!cachedSchoolYearsPromise) {
cachedSchoolYearsPromise = fetchSchoolYears()
.then((years) => {
cachedSchoolYears = years
return years
})
.finally(() => {
cachedSchoolYearsPromise = null
})
}
return cachedSchoolYearsPromise
}
export function useSchoolYearOptions({
legacyYears,
preferredYear,
enabled = true,
}: {
legacyYears?: LegacySchoolYearValue[]
preferredYear?: string | null
enabled?: boolean
}) {
const [canonicalYears, setCanonicalYears] = useState<SchoolYearRecord[]>(cachedSchoolYears ?? [])
useEffect(() => {
if (!enabled) return
if (cachedSchoolYears) {
setCanonicalYears(cachedSchoolYears)
return
}
let cancelled = false
loadCanonicalSchoolYears()
.then((years) => {
if (!cancelled) setCanonicalYears(years)
})
.catch(() => {
if (!cancelled) setCanonicalYears([])
})
return () => {
cancelled = true
}
}, [enabled])
const options = useMemo(
() =>
mergeSchoolYearOptions({
canonicalYears,
legacyYears,
extraYears: [preferredYear],
}),
[canonicalYears, legacyYears, preferredYear],
)
const selectedYear = useMemo(
() => defaultSchoolYearOption(options, preferredYear),
[options, preferredYear],
)
const currentYear = useMemo(
() => options.find((option) => option.isCurrent)?.value ?? selectedYear,
[options, selectedYear],
)
return {
canonicalYears,
currentYear,
options,
selectedYear,
}
}
+27
View File
@@ -0,0 +1,27 @@
import { useMemo } from 'react'
import { useSchoolYear } from '../context/SchoolYearContext'
export function useSelectedSchoolYearParams() {
const { selectedSchoolYear, selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
return useMemo(() => {
const schoolYear = selectedSchoolYearName
const query = new URLSearchParams()
const headers: Record<string, string> = {}
if (schoolYear) {
query.set('school_year', schoolYear)
headers['X-School-Year'] = schoolYear
}
const queryString = query.toString()
return {
schoolYear,
query,
queryString,
headers,
isReadOnly: selectedSchoolYear ? isReadOnlyYear : false,
}
}, [isReadOnlyYear, selectedSchoolYear, selectedSchoolYearName])
}
+7 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthProvider'
import { CiPublicFooter } from '../components/CiPartials'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
@@ -169,7 +170,6 @@ export function MainLayout() {
<div className="container-fluid">
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
<img className="main-layout-logo" src="/logo.png" alt="Al Rahma Sunday School logo" />
Al Rahma Sunday School
</Link>
<button
className="navbar-toggler"
@@ -214,6 +214,11 @@ export function MainLayout() {
))}
</ul>
</nav>
{user ? (
<div className="d-none d-lg-flex align-items-center me-2">
<SchoolYearSelector compact />
</div>
) : null}
{user ? (
<div
ref={clusterRef}
@@ -333,6 +338,7 @@ export function MainLayout() {
</header>
<main className="container mt-4 flex-grow-1">
<ReadOnlyBanner />
<Outlet />
</main>
+45 -6
View File
@@ -1,13 +1,34 @@
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'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import {
isExternalNavUrl,
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 +41,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 +59,7 @@ function normalizeNavItems(payload: unknown): NavItem[] {
}
}
return tree
return sortNavTree(tree)
}
function branchContainsPath(item: NavItem, pathname: string): boolean {
@@ -59,8 +80,6 @@ function NavTree({ items, pathname }: { items: NavItem[]; pathname: string }) {
function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pathname: string }) {
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 +90,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 +100,8 @@ function NavBranch({ item, depth, pathname }: { item: NavItem; depth: number; pa
}
}, [hasChildren, item, pathname])
if (!enabled) return null
const linkInner = (
<>
{icon}
@@ -187,6 +208,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,10 +227,15 @@ export function ManagementLayout() {
return () => {
cancelled = true
}
}, [])
}, [location.hash, location.pathname, location.search, logout, navigate])
const navTree = useMemo(() => normalizeNavItems(items), [items])
// Auto-hide sidebar after navigating to a subject
useEffect(() => {
setSidebarOpen(false)
}, [location.pathname])
return (
<div className={`management-app d-flex flex-column min-vh-100 ${sidebarOpen ? 'mgmt-sidebar-open' : ''}`}>
<header className="navbar navbar-dark sticky-top border-bottom shadow-sm px-3 py-2">
@@ -207,6 +244,7 @@ export function ManagementLayout() {
Al Rahma Sunday School
</Link>
<div className="d-flex align-items-center gap-2">
<SchoolYearSelector compact />
<span className="navbar-text small mb-0">{user?.name}</span>
<button
type="button"
@@ -278,6 +316,7 @@ export function ManagementLayout() {
</aside>
<main className="content management-main">
<ReadOnlyBanner />
<Outlet />
</main>
</div>
+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_URL` when you want the browser to call Laravel directly, e.g.
* `VITE_API_URL=http://localhost:8000 npm run dev`.
*
* **Production build:** `VITE_API_ORIGIN` if set, else `PROD_API_ORIGIN`.
* If `VITE_API_URL` is not set in dev, `vite.config.ts` proxies `/api` to
* `VITE_PROXY_API` or `http://localhost:8000` by default.
*/
export function apiOrigin(): string {
const v = import.meta.env.VITE_API_URL
if (typeof v === 'string' && v.trim()) {
return v.replace(/\/$/, '')
}
if (import.meta.env.DEV) {
return ''
}
const v = import.meta.env.VITE_API_ORIGIN
if (typeof v === 'string' && v.trim()) {
return v.replace(/\/$/, '')
}
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.isCurrent), record.id)
}
function optionFromLegacy(value: LegacySchoolYearValue): SchoolYearOption | null {
if (typeof value === 'string') {
return makeOption(value)
}
return makeOption(
value.name ?? value.school_year ?? '',
value.status ?? undefined,
Boolean(value.is_current),
value.id,
)
}
function compareSchoolYears(left: SchoolYearOption, right: SchoolYearOption): number {
if (left.isCurrent !== right.isCurrent) {
return Number(right.isCurrent) - Number(left.isCurrent)
}
return right.value.localeCompare(left.value, undefined, {
numeric: true,
sensitivity: 'base',
})
}
export function mergeSchoolYearOptions(params: {
canonicalYears?: SchoolYearRecord[]
legacyYears?: LegacySchoolYearValue[]
extraYears?: Array<string | null | undefined>
}): SchoolYearOption[] {
const map = new Map<string, SchoolYearOption>()
for (const record of params.canonicalYears ?? []) {
const option = optionFromRecord(record)
if (option) map.set(option.value, option)
}
for (const value of params.legacyYears ?? []) {
const option = optionFromLegacy(value)
if (!option || map.has(option.value)) continue
map.set(option.value, option)
}
for (const extraYear of params.extraYears ?? []) {
const option = makeOption(String(extraYear ?? ''))
if (!option || map.has(option.value)) continue
map.set(option.value, option)
}
return [...map.values()].sort(compareSchoolYears)
}
export function defaultSchoolYearOption(
options: SchoolYearOption[],
preferredYear?: string | null,
): string {
const preferred = normalizeSchoolYearValue(preferredYear)
if (preferred && options.some((option) => option.value === preferred)) {
return preferred
}
const current = options.find((option) => option.isCurrent)
if (current) return current.value
return options[0]?.value ?? buildCalendarSchoolYear()
}
export function canonicalSchoolYearName(records: SchoolYearRecord[]): string | null {
if (records.length === 0) return null
const current = records.find((record) => record.is_current)
return current?.name ?? records[0]?.name ?? null
}
+101
View File
@@ -0,0 +1,101 @@
import type { SchoolYearRecord } from '../api/schoolYears'
export const SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY = 'alrahma_selected_school_year_name'
export const SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY = 'alrahma_selected_school_year_status'
function safeLocalStorage(): Storage | null {
try {
return typeof window !== 'undefined' ? window.localStorage : null
} catch {
return null
}
}
export function isCurrentSchoolYear(year: Pick<SchoolYearRecord, 'is_current'> & { isCurrent?: boolean }): boolean {
return Boolean(year.is_current || year.isCurrent)
}
export function isReadOnlySchoolYearStatus(status: string | null | undefined): boolean {
const normalized = String(status ?? '').trim().toLowerCase()
return normalized === 'closed' || normalized === 'archived'
}
export function isReadOnlySchoolYear(year: SchoolYearRecord | null | undefined): boolean {
return year ? isReadOnlySchoolYearStatus(year.status) : false
}
export function getStoredSelectedSchoolYearName(): string | null {
const storage = safeLocalStorage()
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)?.trim() ?? ''
return value || null
}
export function getStoredSelectedSchoolYearStatus(): string | null {
const storage = safeLocalStorage()
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)?.trim() ?? ''
return value || null
}
export function storeSelectedSchoolYear(year: SchoolYearRecord | null): void {
const storage = safeLocalStorage()
if (!storage) return
if (!year) {
storage.removeItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)
storage.removeItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)
return
}
storage.setItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY, year.name)
storage.setItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY, year.status)
}
export function clearSelectedSchoolYear(): void {
storeSelectedSchoolYear(null)
}
export function selectedSchoolYearHeaders(): Record<string, string> {
const name = getStoredSelectedSchoolYearName()
const status = getStoredSelectedSchoolYearStatus()
const headers: Record<string, string> = {}
if (name) {
headers['X-School-Year'] = name
}
if (status) {
headers['X-School-Year-Status'] = status
}
return headers
}
export function buildSchoolYearQuery(params: {
schoolYearName?: string | null
existing?: string | URLSearchParams
}): string {
const query = new URLSearchParams(params.existing)
const name = params.schoolYearName?.trim()
if (name) {
query.set('school_year', name)
}
const value = query.toString()
return value ? `?${value}` : ''
}
export function withSelectedSchoolYearUrl(url: string): string {
const name = getStoredSelectedSchoolYearName()
if (!name) return url
const [base, hash = ''] = url.split('#', 2)
const [path, rawQuery = ''] = base.split('?', 2)
const query = new URLSearchParams(rawQuery)
query.delete('school_year_id')
query.delete('year')
query.set('school_year', name)
const next = `${path}?${query.toString()}`
return hash ? `${next}#${hash}` : next
}
+27 -19
View File
@@ -37,6 +37,27 @@ type FlatContact = {
phone: string
}
type SortKey = keyof Omit<FlatContact, 'contactId' | 'parentId'>
function SortTh({ col, label, sortKey, sortDir, onSort }: {
col: SortKey
label: string
sortKey: SortKey
sortDir: 'asc' | 'desc'
onSort: (col: SortKey) => void
}) {
const active = sortKey === col
return (
<th
role="button"
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
onClick={() => onSort(col)}
>
{label} <span className="text-muted small">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th>
)
}
export function AdminEmergencyContactIndexPage() {
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
const [error, setError] = useState<string | null>(null)
@@ -82,7 +103,7 @@ export function AdminEmergencyContactIndexPage() {
const displayed = useMemo(() => {
const q = search.trim().toLowerCase()
let rows = q
const rows = q
? flatRows.filter((r) =>
r.parentName.toLowerCase().includes(q) ||
r.students.toLowerCase().includes(q) ||
@@ -97,19 +118,6 @@ export function AdminEmergencyContactIndexPage() {
})
}, [flatRows, search, sortKey, sortDir])
function SortTh({ col, label }: { col: typeof sortKey; label: string }) {
const active = sortKey === col
return (
<th
role="button"
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
onClick={() => toggleSort(col)}
>
{label} <span className="text-muted small">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th>
)
}
return (
<PageShell title="Emergency Contact Information">
{error ? <div className="alert alert-danger">{error}</div> : null}
@@ -132,11 +140,11 @@ export function AdminEmergencyContactIndexPage() {
<table className="table table-bordered table-striped align-middle">
<thead className="table-dark">
<tr>
<SortTh col="parentName" label="Parent Name" />
<SortTh col="students" label="Students" />
<SortTh col="contactName" label="Contact Name" />
<SortTh col="relation" label="Relation" />
<SortTh col="phone" label="Phone" />
<SortTh col="parentName" label="Parent Name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortTh col="students" label="Students" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortTh col="contactName" label="Contact Name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortTh col="relation" label="Relation" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<SortTh col="phone" label="Phone" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<th>Actions</th>
</tr>
</thead>
+315 -125
View File
@@ -2,6 +2,11 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 're
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import './AdminDailyAttendance.css'
import { ApiHttpError } from '../api/http'
import { fetchParentProfile, fetchParentProfiles, updateParentProfile, type ParentProfileDetail, type ParentProfileSummary } from '../api/parentProfiles'
import { CiAcademicFilter } from '../components/CiPartials'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import { useSchoolYear } from '../context/SchoolYearContext'
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
import {
assignTeacherClass,
assignStudentClass,
@@ -23,7 +28,6 @@ import {
fetchClassAssignmentOverview,
fetchClassSections,
fetchExamDraftAdminData,
fetchFamilyAdminIndex,
fetchGradingOverview,
fetchIpBans,
fetchLateSlipLogs,
@@ -978,17 +982,17 @@ export function AdminCalendarAddEventPage() {
}, [])
if (loading && !options) {
return <AdminParityShell title="Add Event"><p className="text-muted">Loading</p></AdminParityShell>
return <AdminParityShell title="Add School Calendar Entry"><p className="text-muted">Loading</p></AdminParityShell>
}
return (
<CalendarEventForm
title="Add Event"
title="Add School Calendar Entry"
initial={{
event_types: options?.event_types ?? [],
defaults: options?.defaults,
}}
submitLabel="Add Event"
submitLabel="Add School Calendar Entry"
submitting={false}
onSubmit={async (payload) => {
await createAdministratorCalendarEvent(payload as never)
@@ -1027,18 +1031,18 @@ export function AdminCalendarEditPage() {
}, [eventId])
if (loading || !event) {
return <AdminParityShell title="Edit Event"><p className="text-muted">Loading</p></AdminParityShell>
return <AdminParityShell title="Edit School Calendar Entry"><p className="text-muted">Loading</p></AdminParityShell>
}
return (
<CalendarEventForm
title="Edit Event"
title="Edit School Calendar Entry"
initial={{
...event,
event_types: options?.event_types ?? [],
defaults: options?.defaults,
}}
submitLabel="Update Event"
submitLabel="Update School Calendar Entry"
submitting={false}
onSubmit={async (payload) => {
await updateAdministratorCalendarEvent(Number(eventId), payload)
@@ -1050,12 +1054,6 @@ export function AdminCalendarEditPage() {
/** School calendar admin — `GET /api/v1/settings/school-calendar/events` (JWT). SPA: `/app/administrator/events` or `calendar_view`. */
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,24 +1061,28 @@ export function AdminCalendarViewPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const { options, currentYear } = useSchoolYearOptions({
preferredYear: schoolYear,
})
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)
try {
const data = await fetchAdministratorCalendarEvents({
schoolYear: schoolYear || null,
semester: null,
semester: semester || null,
})
setEvents(data.data?.events ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load calendar events.')
setError(e instanceof Error ? e.message : 'Unable to load school calendar entries.')
} finally {
setLoading(false)
}
@@ -1088,16 +1090,16 @@ export function AdminCalendarViewPage() {
useEffect(() => {
void load()
}, [schoolYear])
}, [schoolYear, semester])
async function onDelete(eventId: number) {
if (!window.confirm('Delete this event?')) return
if (!window.confirm('Delete this school calendar entry?')) return
await deleteAdministratorCalendarEvent(eventId)
await load()
}
return (
<AdminParityShell title="Event list">
<AdminParityShell title="School Calendar">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex gap-2 mb-3 flex-wrap justify-content-between align-items-center">
@@ -1108,7 +1110,7 @@ export function AdminCalendarViewPage() {
to={`/app/administrator/calendar_add_event${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}
className="btn btn-success"
>
Add New Event
Add School Calendar Entry
</Link>
</div>
@@ -1117,21 +1119,49 @@ export function AdminCalendarViewPage() {
onSubmit={(event) => {
event.preventDefault()
const year = String(new FormData(event.currentTarget).get('school_year') ?? '').trim()
const semester = String(new FormData(event.currentTarget).get('semester') ?? '').trim()
const next = new URLSearchParams()
if (year) next.set('school_year', year)
if (semester) next.set('semester', semester)
setSearchParams(next)
}}
>
<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>
<label htmlFor="cal-semester" className="form-label">Semester</label>
<select
id="cal-semester"
name="semester"
className="form-select"
defaultValue={semester}
>
<option value="">-</option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="d-flex gap-2 align-self-end">
<button className="btn btn-secondary" type="submit">Apply</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }))}
onClick={() =>
setSearchParams(new URLSearchParams(currentYear ? { school_year: currentYear } : {}))
}
>
Reset
</button>
@@ -1147,15 +1177,18 @@ export function AdminCalendarViewPage() {
<th>Type</th>
<th>Description</th>
<th>Date</th>
<th>Audience</th>
<th>Notify Parent</th>
<th>Notify Teacher</th>
<th>Notify Admin</th>
<th>No School</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-muted">Loading events</td></tr>
<tr><td colSpan={10} className="text-muted">Loading school calendar entries</td></tr>
) : events.length === 0 ? (
<tr><td colSpan={7} className="text-muted">No events found.</td></tr>
<tr><td colSpan={10} className="text-muted">No school calendar entries found.</td></tr>
) : (
events.map((row) => (
<tr key={row.id ?? row.title + row.start}>
@@ -1164,7 +1197,10 @@ export function AdminCalendarViewPage() {
<td>{String(row.extendedProps?.event_type ?? '—')}</td>
<td>{row.description || '—'}</td>
<td>{formatDate(row.start)}</td>
<td>{eventAudienceBadges(row).join(', ') || '—'}</td>
<td className="text-center"><input type="checkbox" disabled checked={Boolean(row.extendedProps?.notify_parent)} readOnly /></td>
<td className="text-center"><input type="checkbox" disabled checked={Boolean(row.extendedProps?.notify_teacher)} readOnly /></td>
<td className="text-center"><input type="checkbox" disabled checked={Boolean(row.extendedProps?.notify_admin)} readOnly /></td>
<td className="text-center"><input type="checkbox" disabled checked={Boolean(row.extendedProps?.no_school)} readOnly /></td>
<td className="d-flex gap-2 flex-wrap">
{row.id ? (
<>
@@ -1176,7 +1212,7 @@ export function AdminCalendarViewPage() {
</button>
</>
) : (
<span className="text-muted small">Meeting event</span>
<span className="text-muted small">Meeting entry</span>
)}
</td>
</tr>
@@ -1307,6 +1343,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 +1356,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)
@@ -1334,7 +1374,7 @@ export function AdminClassAssignmentPage() {
function toggleSection(key: string) {
setExpandedSections((prev) => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
if (next.has(key)) next.delete(key); else next.add(key)
return next
})
}
@@ -1397,11 +1437,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>
@@ -2527,7 +2567,7 @@ export function AdminExamDraftsPage() {
const allowedExtensions = data?.allowed_extensions ?? ['doc', 'docx', 'pdf']
const maxUploadMb = Math.max(1, Math.round((data?.max_upload_bytes ?? 12 * 1024 * 1024) / 1024 / 1024))
const classSections = data?.class_sections ?? []
const drafts = (data?.drafts ?? []).filter((draft) => !Boolean(draft.is_legacy))
const drafts = (data?.drafts ?? []).filter((draft) => !draft.is_legacy)
const legacyByClass = data?.legacy_by_class ?? {}
const draftsByClass = useMemo(() => {
@@ -3449,19 +3489,28 @@ export function AdminPrintNotificationAdminsPage() {
}
export function AdminParentProfilePage() {
const [guardians, setGuardians] = useState<NotificationAdminRow[]>([])
const [selectedGuardianId, setSelectedGuardianId] = useState<number | null>(null)
const [families, setFamilies] = useState<Array<Record<string, unknown>>>([])
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const [searchParams, setSearchParams] = useSearchParams()
const [query, setQuery] = useState(searchParams.get('q') ?? '')
const [parents, setParents] = useState<ParentProfileSummary[]>([])
const [selectedParentId, setSelectedParentId] = useState<number | null>(Number(searchParams.get('parent_id')) || null)
const [detail, setDetail] = useState<ParentProfileDetail | null>(null)
const [pagination, setPagination] = useState({ page: Number(searchParams.get('page')) || 1, per_page: 25, total: 0 })
const [loading, setLoading] = useState(true)
const [detailLoading, setDetailLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!selectedSchoolYearName) return
;(async () => {
setLoading(true)
try {
const data = await fetchFamilyAdminIndex()
setGuardians(data.data?.searchGuardians ?? [])
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
const page = Number(searchParams.get('page')) || 1
const q = searchParams.get('q') ?? ''
const data = await fetchParentProfiles({ schoolYear: selectedSchoolYearName, q, page, perPage: 25 })
setParents(data.parents ?? [])
setPagination(data.pagination ?? { page, per_page: 25, total: 0 })
setQuery(q)
setError(null)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profiles.')
@@ -3469,31 +3518,98 @@ export function AdminParentProfilePage() {
setLoading(false)
}
})()
}, [])
}, [searchParams, selectedSchoolYearName])
async function loadGuardian(guardianId: number) {
setSelectedGuardianId(guardianId)
useEffect(() => {
if (!selectedSchoolYearName || !selectedParentId) {
setDetail(null)
return
}
;(async () => {
setDetailLoading(true)
try {
setDetail(await fetchParentProfile(selectedParentId, selectedSchoolYearName))
setError(null)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profile.')
} finally {
setDetailLoading(false)
}
})()
}, [selectedParentId, selectedSchoolYearName])
function applySearch(event: FormEvent) {
event.preventDefault()
if (!selectedSchoolYearName) return
const next = new URLSearchParams(searchParams)
next.set('school_year', selectedSchoolYearName)
next.set('page', '1')
if (query.trim()) next.set('q', query.trim())
else next.delete('q')
setSearchParams(next)
}
function selectParent(parentId: number) {
if (!selectedSchoolYearName) return
setSelectedParentId(parentId)
const next = new URLSearchParams(searchParams)
next.set('school_year', selectedSchoolYearName)
next.set('parent_id', String(parentId))
setSearchParams(next)
}
function money(value: unknown): string {
return Number(value ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD' })
}
async function saveParentProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!detail?.parent || !selectedParentId || isReadOnlyYear || !selectedSchoolYearName) return
setError(null)
try {
const data = await fetchFamilyAdminIndex({ guardianId })
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? [])
const form = new FormData(event.currentTarget)
const payload = Object.fromEntries(form.entries())
const updated = await updateParentProfile(selectedParentId, selectedSchoolYearName, payload)
setDetail(updated)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load families.')
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to update parent profile.')
}
}
const parent = detail?.parent ?? null
const fullName = parent ? `${String(parent.firstname ?? '')} ${String(parent.lastname ?? '')}`.trim() : ''
return (
<AdminParityShell title="Parent Profiles">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div className="d-flex align-items-center gap-2">
<SchoolYearSelector compact />
{selectedSchoolYearName ? <span className="badge bg-secondary">{selectedSchoolYearName}</span> : null}
</div>
<form className="d-flex gap-2" onSubmit={applySearch}>
<input className="form-control" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search parents, email, phone, student" />
<button type="submit" className="btn btn-primary">Search</button>
</form>
</div>
<ReadOnlyBanner className="mb-3" />
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3">
<div className="col-lg-4">
<div className="card">
<div className="card-header">Guardians</div>
<div className="list-group list-group-flush" style={{ maxHeight: 520, overflow: 'auto' }}>
{loading ? <div className="list-group-item text-muted">Loading guardians</div> : guardians.map((guardian) => (
<button key={guardian.id} type="button" className={`list-group-item list-group-item-action ${selectedGuardianId === guardian.id ? 'active' : ''}`} onClick={() => void loadGuardian(guardian.id)}>
<div>{`${guardian.firstname ?? ''} ${guardian.lastname ?? ''}`.trim()}</div>
<div className="small">{guardian.email ?? ''}</div>
<div className="card-header d-flex justify-content-between">
<span>Parents</span>
<span className="text-muted small">{pagination.total} found</span>
</div>
<div className="list-group list-group-flush" style={{ maxHeight: 640, overflow: 'auto' }}>
{loading ? <div className="list-group-item text-muted">Loading parents...</div> : null}
{!loading && parents.length === 0 ? <div className="list-group-item text-muted">No selected-year parents found.</div> : null}
{parents.map((parentRow) => (
<button key={parentRow.id} type="button" className={`list-group-item list-group-item-action ${selectedParentId === parentRow.id ? 'active' : ''}`} onClick={() => selectParent(parentRow.id)}>
<div className="fw-semibold">{`${parentRow.firstname ?? ''} ${parentRow.lastname ?? ''}`.trim() || `Parent #${parentRow.id}`}</div>
<div className="small">{parentRow.email ?? ''}</div>
<div className="small text-muted">
Students: {parentRow.selected_year_students_count ?? 0} | Balance: {money(parentRow.balance)}
</div>
</button>
))}
</div>
@@ -3501,21 +3617,87 @@ export function AdminParentProfilePage() {
</div>
<div className="col-lg-8">
<div className="card">
<div className="card-header">Families</div>
<div className="card-header d-flex justify-content-between">
<span>{fullName || 'Parent Detail'}</span>
{detailLoading ? <span className="text-muted small">Loading...</span> : null}
</div>
<div className="card-body">
{families.length === 0 ? <p className="text-muted mb-0">Select a guardian to view family details.</p> : families.map((family) => (
<div key={String(family.id ?? '')} className="border rounded p-3 mb-3">
<h5 className="mb-1">{String(family.household_name ?? 'Household')}</h5>
<div className="small text-muted mb-2">{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}</div>
<div className="mb-2"><strong>Phone:</strong> {String(family.primary_phone ?? '—')}</div>
<div><strong>Students</strong></div>
<ul className="mb-0">
{Array.isArray(family.students) && family.students.length > 0 ? family.students.map((student, idx) => (
<li key={idx}>{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()} {student.class_section_name ? `(${String(student.class_section_name)})` : ''}</li>
)) : <li className="text-muted">No students linked.</li>}
</ul>
{!detail ? <p className="text-muted mb-0">Select a parent to view selected-year details.</p> : null}
{detail && parent ? (
<div className="d-grid gap-3">
<form className="row g-2" onSubmit={saveParentProfile}>
{['firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip'].map((field) => (
<div className={field === 'address_street' ? 'col-12' : 'col-md-6'} key={field}>
<label className="form-label text-capitalize">{field.replace('_', ' ')}</label>
<input className="form-control" name={field} defaultValue={String(parent[field] ?? '')} disabled={isReadOnlyYear} />
</div>
))}
<div className="col-12">
<button type="submit" className="btn btn-primary" disabled={isReadOnlyYear}>Save Profile</button>
</div>
</form>
<div className="row g-2">
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Balance</div><strong>{money(detail.finance_summary.balance)}</strong></div></div>
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Unpaid</div><strong>{money(detail.finance_summary.positive_unpaid_balance)}</strong></div></div>
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Credit</div><strong>{money(detail.finance_summary.credit_balance)}</strong></div></div>
</div>
<section>
<h5>Students</h5>
{detail.students.length === 0 ? <p className="text-muted">No students for this school year.</p> : (
<table className="table table-sm align-middle">
<tbody>
{detail.students.map((student) => (
<tr key={String(student.id)}>
<td>{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()}</td>
<td>{String(student.class_section_name ?? student.grade ?? '')}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section>
<h5>Families</h5>
{detail.families.length === 0 ? <p className="text-muted">No selected-year families.</p> : detail.families.map((family) => (
<div key={String(family.id)} className="border rounded p-2 mb-2">
<strong>{String(family.household_name ?? 'Household')}</strong>
<div className="small text-muted">{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}</div>
</div>
))}
</section>
<section>
<h5>Emergency Contacts</h5>
{detail.emergency_contacts.length === 0 ? <p className="text-muted">No selected-year emergency contacts.</p> : detail.emergency_contacts.map((contact) => (
<div key={String(contact.id)} className="border rounded p-2 mb-2">
<strong>{String(contact.emergency_contact_name ?? '')}</strong>
<div className="small text-muted">{String(contact.relation ?? '')} {String(contact.cellphone ?? '')} {String(contact.email ?? '')}</div>
</div>
))}
</section>
<section>
<h5>Invoices</h5>
{detail.invoices.length === 0 ? <p className="text-muted">No selected-year invoices.</p> : (
<table className="table table-sm align-middle">
<thead><tr><th>Invoice</th><th>Status</th><th className="text-end">Balance</th></tr></thead>
<tbody>
{detail.invoices.map((invoice) => (
<tr key={String(invoice.id)}>
<td>{String(invoice.invoice_number ?? invoice.id ?? '')}</td>
<td>{String(invoice.status ?? '')}</td>
<td className="text-end">{money(invoice.balance)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
))}
) : null}
</div>
</div>
</div>
@@ -3644,15 +3826,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 +3970,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 +4002,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 +4058,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)
@@ -3932,7 +4099,6 @@ export function AdminSectionsAutoDistributePage() {
async function runAll() {
for (const row of rows) {
// eslint-disable-next-line no-await-in-loop
await runDistribution(row)
}
}
@@ -3941,15 +4107,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 +4147,7 @@ export function AdminSectionsAutoDistributePage() {
}
export function AdminStudentClassAssignmentPage() {
const [searchParams] = useSearchParams()
type StudentAssignmentSortKey =
| 'name'
| 'age'
@@ -3988,7 +4156,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 +4271,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 +4378,7 @@ export function AdminStudentClassAssignmentPage() {
}
export function AdminStudentProfilesPage() {
const [searchParams] = useSearchParams()
type StudentProfileSortKey =
| 'school_id'
| 'firstname'
@@ -4222,7 +4390,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)
@@ -4315,8 +4483,8 @@ export function AdminStudentProfilesPage() {
student.registration_grade,
student.dob,
student.registration_date,
Boolean(student.is_active) ? 'yes active' : 'no inactive',
Boolean(student.photo_consent) ? 'yes consent' : 'no consent',
student.is_active ? 'yes active' : 'no inactive',
student.photo_consent ? 'yes consent' : 'no consent',
]
.map((value) => String(value ?? '').toLowerCase())
.join(' ')
@@ -4345,10 +4513,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"
@@ -4388,8 +4556,8 @@ export function AdminStudentProfilesPage() {
<td>{student.age ?? '—'}</td>
<td>{student.gender ?? '—'}</td>
<td>{student.registration_grade ?? '—'}</td>
<td>{Boolean(student.is_active) ? 'Yes' : 'No'}</td>
<td>{Boolean(student.photo_consent) ? 'Yes' : 'No'}</td>
<td>{student.is_active ? 'Yes' : 'No'}</td>
<td>{student.photo_consent ? 'Yes' : 'No'}</td>
<td>{formatDate(student.registration_date)}</td>
<td><button className="btn btn-sm btn-primary" type="button" onClick={() => void openDetails(student)}>View / Edit</button></td>
</tr>
@@ -4452,9 +4620,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 +4632,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 +4662,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 +4700,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 +4759,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 +4777,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 +4823,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 +4874,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 +4920,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 +4972,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">
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchSchoolYearParentBalances,
fetchSchoolYearPromotionPreview,
fetchSchoolYearSummary,
type SchoolYearParentBalancePreview,
type SchoolYearPromotionPreview,
type SchoolYearSummary,
} from '../../api/schoolYears'
import { SchoolYearStatusBadge } from '../../components/schoolYear'
function normalizeApiError(error: unknown, fallback: string): string {
if (error instanceof ApiHttpError) return error.message || fallback
if (error instanceof Error) return error.message
return fallback
}
function formatMoney(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
export function SchoolYearDetailPage() {
const [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year')?.trim() ?? ''
const [summary, setSummary] = useState<SchoolYearSummary | null>(null)
const [promotionPreview, setPromotionPreview] = useState<SchoolYearPromotionPreview | null>(null)
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!schoolYear) {
setError('Missing school_year query parameter.')
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
;(async () => {
try {
const [nextSummary, nextPromotion, nextBalances] = await Promise.all([
fetchSchoolYearSummary(schoolYear),
fetchSchoolYearPromotionPreview(schoolYear),
fetchSchoolYearParentBalances(schoolYear),
])
if (!cancelled) {
setSummary(nextSummary)
setPromotionPreview(nextPromotion)
setParentBalances(nextBalances)
}
} catch (loadError) {
if (!cancelled) setError(normalizeApiError(loadError, 'Unable to load school year detail.'))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [schoolYear])
const totals = summary?.totals
const promotion = promotionPreview?.summary
const balances = parentBalances?.summary
const rows = useMemo(() => parentBalances?.rows.slice(0, 25) ?? [], [parentBalances])
return (
<div className="container-fluid py-4">
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
<li className="breadcrumb-item"><Link to="/app/home">Home</Link></li>
<li className="breadcrumb-item"><Link to="/app/admin/school-years">School years</Link></li>
<li className="breadcrumb-item active" aria-current="page">Summary</li>
</ol>
</nav>
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 className="h3 mb-1">School Year Summary</h1>
<p className="text-muted mb-0">
Academic promotion, parent-level balance exposure, and closure readiness in one place.
</p>
</div>
{summary?.school_year ? <SchoolYearStatusBadge status={summary.school_year.status} /> : null}
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading school year summary</p> : null}
{summary ? (
<>
<div className="row g-3 mb-4">
<div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Students considered</div>
<div className="display-6">{totals?.students_considered ?? 0}</div>
</div></div>
</div>
<div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Students blocked</div>
<div className="display-6">{totals?.students_blocked ?? 0}</div>
</div></div>
</div>
<div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Unpaid carryover parents</div>
<div className="display-6">{totals?.parents_with_positive_balance ?? totals?.parents_with_balances ?? 0}</div>
</div></div>
</div>
<div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Net transfer impact</div>
<div className="display-6">${formatMoney(totals?.net_balance_impact ?? totals?.net_balance_to_transfer)}</div>
</div></div>
</div>
</div>
<div className="row g-4">
<div className="col-xl-5">
<div className="card shadow-sm h-100">
<div className="card-body">
<h2 className="h5">Promotion summary</h2>
<dl className="row mb-0 small">
<dt className="col-6">Promote</dt><dd className="col-6 text-end">{promotion?.promote ?? 0}</dd>
<dt className="col-6">Repeat</dt><dd className="col-6 text-end">{promotion?.repeat ?? 0}</dd>
<dt className="col-6">Graduate</dt><dd className="col-6 text-end">{promotion?.graduate ?? 0}</dd>
<dt className="col-6">Withdraw</dt><dd className="col-6 text-end">{promotion?.withdraw ?? 0}</dd>
<dt className="col-6">Missing decision</dt><dd className="col-6 text-end">{promotion?.hold ?? 0}</dd>
</dl>
</div>
</div>
</div>
<div className="col-xl-7">
<div className="card shadow-sm h-100">
<div className="card-body">
<h2 className="h5">Parent balance preview</h2>
<div className="small text-muted mb-3">
{balances?.parents_with_positive_balance ?? 0} parents with unpaid balances,
{balances?.parents_with_credit_balance ?? 0} parents with credits,
net ${formatMoney(balances?.net_balance_impact ?? balances?.net_balance_to_transfer)}.
</div>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead><tr><th>Parent</th><th>Students</th><th className="text-end">Unpaid</th><th className="text-end">Credits</th><th className="text-end">Net</th></tr></thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={5} className="text-muted">No balance rows found.</td></tr>
) : rows.map((row) => (
<tr key={row.parent_id}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td>
<td className="text-end">${formatMoney(row.positive_unpaid_balance ?? row.amount_to_transfer)}</td>
<td className="text-end">${formatMoney(row.credit_balance)}</td>
<td className="text-end">${formatMoney(row.net_balance)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</>
) : null}
</div>
)
}
@@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchSchoolYearBalanceTransferReport,
fetchSchoolYearClosingReport,
type SchoolYearBalanceTransferReport,
type SchoolYearClosingReport,
} from '../../api/schoolYears'
type ReportType = 'closing' | 'balance-transfers'
function normalizeApiError(error: unknown, fallback: string): string {
if (error instanceof ApiHttpError) return error.message || fallback
if (error instanceof Error) return error.message
return fallback
}
function formatMoney(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString()
}
function ClosingReportView({ report }: { report: SchoolYearClosingReport }) {
const totals = report.totals ?? {}
return (
<div className="card shadow-sm">
<div className="card-body">
<h2 className="h5 mb-3">Closing Report</h2>
<dl className="row small mb-4">
<dt className="col-md-3">Old school year</dt>
<dd className="col-md-9">{report.old_school_year?.name ?? '—'}</dd>
<dt className="col-md-3">New school year</dt>
<dd className="col-md-9">{report.new_school_year?.name ?? '—'}</dd>
<dt className="col-md-3">Closed by</dt>
<dd className="col-md-9">{report.closed_by ?? '—'}</dd>
<dt className="col-md-3">Closed at</dt>
<dd className="col-md-9">{formatDateTime(report.closed_at)}</dd>
<dt className="col-md-3">Audit reference</dt>
<dd className="col-md-9">{report.audit_reference ?? '—'}</dd>
</dl>
<div className="row g-3 mb-4">
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Promoted</div><div className="h4 mb-0">{totals.students_promoted ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Repeated</div><div className="h4 mb-0">{totals.students_repeated ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Graduated</div><div className="h4 mb-0">{totals.students_graduated ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Parents with unpaid balances</div><div className="h4 mb-0">{totals.parents_with_unpaid_balances ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Unpaid transferred</div><div className="h4 mb-0">${formatMoney(totals.total_unpaid_balance_transferred)}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Credits carried or reported</div><div className="h4 mb-0">${formatMoney(totals.total_credit_carried_or_reported ?? totals.total_credit_transferred)}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Net balance impact</div><div className="h4 mb-0">${formatMoney(totals.net_balance_impact)}</div></div></div>
</div>
{report.warnings?.length ? (
<div className="alert alert-warning mb-0">
<div className="fw-semibold mb-2">Warnings</div>
<ul className="mb-0">
{report.warnings.map((warning) => <li key={warning}>{warning}</li>)}
</ul>
</div>
) : null}
</div>
</div>
)
}
function BalanceTransfersView({ report }: { report: SchoolYearBalanceTransferReport }) {
const rows = report.rows ?? []
return (
<div className="card shadow-sm">
<div className="card-body">
<h2 className="h5 mb-3">Parent Balance Transfer Report</h2>
<div className="row g-3 mb-4">
<div className="col-md-4"><div className="border rounded p-3"><div className="small text-muted">Parents</div><div className="h4 mb-0">{report.summary?.parent_count ?? rows.length}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3"><div className="small text-muted">Total transferred</div><div className="h4 mb-0">${formatMoney(report.summary?.total_transferred)}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3"><div className="small text-muted">Total credit</div><div className="h4 mb-0">${formatMoney(report.summary?.total_credit)}</div></div></div>
</div>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th>Parent</th>
<th>Old year</th>
<th>New year</th>
<th>Source invoices</th>
<th className="text-end">Old unpaid</th>
<th>New invoice</th>
<th>Status</th>
<th>Transfer date</th>
<th>Created by</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={9} className="text-muted">No transfer rows found.</td></tr>
) : rows.map((row) => (
<tr key={`${row.parent_id}-${row.new_old_balance_invoice_id ?? 'none'}`}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.old_school_year ?? '—'}</td>
<td>{row.new_school_year ?? '—'}</td>
<td>{row.source_invoice_ids?.join(', ') || '—'}</td>
<td className="text-end">${formatMoney(row.old_unpaid_amount)}</td>
<td>{row.new_old_balance_invoice_id ?? '—'}</td>
<td>{row.transfer_status ?? '—'}</td>
<td>{formatDateTime(row.transfer_date)}</td>
<td>{row.created_by ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }) {
const [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year')?.trim() ?? ''
const [closingReport, setClosingReport] = useState<SchoolYearClosingReport | null>(null)
const [balanceReport, setBalanceReport] = useState<SchoolYearBalanceTransferReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const title = useMemo(
() => reportType === 'closing' ? 'Closing Report' : 'Balance Transfer Report',
[reportType],
)
useEffect(() => {
if (!schoolYear) {
setError('Missing school_year query parameter.')
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
setClosingReport(null)
setBalanceReport(null)
;(async () => {
try {
if (reportType === 'closing') {
const report = await fetchSchoolYearClosingReport(schoolYear)
if (!cancelled) setClosingReport(report)
} else {
const report = await fetchSchoolYearBalanceTransferReport(schoolYear)
if (!cancelled) setBalanceReport(report)
}
} catch (loadError) {
if (!cancelled) setError(normalizeApiError(loadError, `Unable to load ${title.toLowerCase()}.`))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [reportType, schoolYear, title])
return (
<div className="container-fluid py-4">
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
<li className="breadcrumb-item"><Link to="/app/home">Home</Link></li>
<li className="breadcrumb-item"><Link to="/app/admin/school-years">School years</Link></li>
<li className="breadcrumb-item active" aria-current="page">{title}</li>
</ol>
</nav>
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 className="h3 mb-1">{title}</h1>
<p className="text-muted mb-0">
Report downloads must be served by authorized backend routes with private file storage.
This screen only displays records returned by those routes.
</p>
</div>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/summary?school_year=${encodeURIComponent(schoolYear)}`}>
Back to summary
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading report</p> : null}
{closingReport ? <ClosingReportView report={closingReport} /> : null}
{balanceReport ? <BalanceTransfersView report={balanceReport} /> : null}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { 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,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import {
fetchClassProgressGroups,
fetchClassProgressMeta,
@@ -20,6 +20,9 @@ export function ClassProgressListPage() {
const [items, setItems] = useState<ClassProgressGroupRow[]>([])
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const selectedSchoolYear = searchParams.get('school_year')?.trim() || null
const selectedSemester = searchParams.get('semester')?.trim() || null
/** Server may later expose `low_progress_section_ids`; until then keep empty (CI disables button). */
const lowProgressSectionIds: number[] = []
@@ -33,10 +36,12 @@ export function ClassProgressListPage() {
async function load() {
try {
const [meta, sections, groups] = await Promise.all([
fetchClassProgressMeta(),
fetchClassSections({ perPage: 200, withStudents: true }),
fetchClassProgressMeta({ schoolYear: selectedSchoolYear, semester: selectedSemester }),
fetchClassSections({ perPage: 200, withStudents: true, schoolYear: selectedSchoolYear, semester: selectedSemester }),
fetchClassProgressGroups({
classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null,
schoolYear: selectedSchoolYear,
semester: selectedSemester,
status: filters.status || null,
weekStart: filters.weekStart || null,
weekEnd: filters.weekEnd || null,
@@ -55,7 +60,7 @@ export function ClassProgressListPage() {
useEffect(() => {
void load()
}, [])
}, [selectedSchoolYear, selectedSemester])
const groupsBySection = useMemo(() => {
const m = new Map<number, ClassProgressGroupRow[]>()
@@ -69,6 +74,26 @@ export function ClassProgressListPage() {
return m
}, [items])
const displaySections = useMemo(() => {
const byId = new Map<number, { class_section_id?: number | null; class_section_name?: string | null }>()
for (const section of classSections) {
const id = Number(section.class_section_id ?? 0)
if (id > 0) byId.set(id, section)
}
for (const item of items) {
const id = Number(item.class_section_id ?? 0)
if (id > 0 && !byId.has(id)) {
byId.set(id, {
class_section_id: id,
class_section_name: item.class_section_name ?? `Section ${id}`,
})
}
}
return Array.from(byId.values()).sort((a, b) =>
String(a.class_section_name ?? '').localeCompare(String(b.class_section_name ?? '')),
)
}, [classSections, items])
const subjectCount = Object.keys(subjectSections).length
function teacherLabelForGroup(group: ClassProgressGroupRow): string {
@@ -218,11 +243,11 @@ export function ClassProgressListPage() {
<div className="card shadow-sm mt-5">
<div className="card-body pt-4">
{classSections.length === 0 ? (
{displaySections.length === 0 ? (
<div className="text-center text-muted py-4">No class sections found.</div>
) : (
<div className="accordion" id="adminProgressAccordion">
{classSections.map((cs) => {
{displaySections.map((cs) => {
const sectionId = Number(cs.class_section_id ?? 0)
const sectionName = cs.class_section_name ?? 'Unknown Section'
const sectionGroups = groupsBySection.get(sectionId) ?? []
@@ -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>
))
) : (
@@ -116,7 +116,6 @@ export function FamilyLegacyImportPage() {
{meta?.instructions_html ? (
<div
className="small"
// eslint-disable-next-line react/no-danger -- API-provided HTML when present
dangerouslySetInnerHTML={{ __html: meta.instructions_html }}
/>
) : (
+1 -1
View File
@@ -182,7 +182,7 @@ export function GradingDecisionsPage() {
totals.Other += 1
}
if (Boolean(row.is_trophy)) {
if (row.is_trophy) {
totals.Trophy += 1
gender.trophy[bucket] += 1
}
+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>
File diff suppressed because it is too large Load Diff
+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">
+39 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
@@ -49,23 +49,32 @@ export function ManualPayPage() {
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [editPaymentId, setEditPaymentId] = useState<number | string | null>(null)
const loadSeq = useRef(0)
const activeTerm = termFromUrl
const load = useCallback(
(term: string) => {
const requestId = ++loadSeq.current
if (!term.trim()) {
setData(null)
setLoading(false)
return
}
setLoading(true)
setError(null)
fetchManualPayPage({ search_term: term.trim() })
.then(setData)
.catch((e: unknown) =>
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.'),
)
.finally(() => setLoading(false))
.then((payload) => {
if (requestId === loadSeq.current) setData(payload)
})
.catch((e: unknown) => {
if (requestId === loadSeq.current) {
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.')
}
})
.finally(() => {
if (requestId === loadSeq.current) setLoading(false)
})
},
[],
)
@@ -76,12 +85,11 @@ export function ManualPayPage() {
}, [termFromUrl, load])
useEffect(() => {
let t: ReturnType<typeof setTimeout>
if (searchInput.trim().length < 2) {
setSuggestions([])
return
}
t = setTimeout(() => {
const t = setTimeout(() => {
fetchManualPaySuggest(searchInput.trim())
.then((rows) => {
setSuggestions(
@@ -104,6 +112,21 @@ export function ManualPayPage() {
navigate({ pathname: '/app/administrator/payment/manual-pay', search: `?search_term=${encodeURIComponent(q)}` })
}
function clearSearch() {
loadSeq.current += 1
setSearchInput('')
setData(null)
setError(null)
setSuggestions([])
setSuggestOpen(false)
setLoading(false)
setShowAddModal(false)
setEditPaymentId(null)
if (previewBlobUrl) URL.revokeObjectURL(previewBlobUrl)
setPreviewBlobUrl(null)
navigate({ pathname: '/app/administrator/payment/manual-pay', search: '' })
}
function pickSuggestion(text: string) {
setSearchInput(text)
setSuggestOpen(false)
@@ -184,6 +207,14 @@ export function ManualPayPage() {
<button className="btn btn-primary" type="submit" disabled={loading}>
Search
</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={clearSearch}
disabled={loading && !searchInput && !data}
>
Clear
</button>
</div>
{suggestOpen && suggestions.length > 0 ? (
<div className="list-group position-absolute w-100 shadow-sm" style={{ zIndex: 10 }}>
@@ -112,7 +112,7 @@ export function AdminPrintRequestsPage() {
<button
type="button"
className="btn btn-link btn-sm p-0"
onClick={() => openPrintRequestFile(encodeURIComponent(fp))}
onClick={() => openPrintRequestFile(id as number | string)}
>
{fp}
</button>
@@ -6,7 +6,8 @@ import {
fetchReportCardCompleteness,
fetchReportCardMeta,
fetchReportCardStudentHtml,
openReportCardDownload,
openReportCardClassDownload,
openReportCardStudentDownload,
reportCardClassUrl,
reportCardStudentUrl,
type ReportCardMeta,
@@ -118,14 +119,12 @@ export function ReportCardManagementPage() {
async function downloadStudent() {
if (!studentId || !schoolYear) return
const url = reportCardStudentUrl(studentId, {
school_year: schoolYear,
semester,
download: true,
report_date: reportDate || undefined,
})
try {
await openReportCardDownload(url)
await openReportCardStudentDownload(studentId, {
school_year: schoolYear,
semester,
report_date: reportDate || undefined,
})
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
}
@@ -133,14 +132,12 @@ export function ReportCardManagementPage() {
async function downloadClass() {
if (!classId || !schoolYear) return
const url = reportCardClassUrl(classId, {
school_year: schoolYear,
semester,
download: true,
report_date: reportDate || undefined,
})
try {
await openReportCardDownload(url)
await openReportCardClassDownload(classId, {
school_year: schoolYear,
semester,
report_date: reportDate || undefined,
})
} catch (e: unknown) {
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
}
@@ -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">
+12 -8
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)
@@ -109,7 +113,7 @@ export function SlipPreviewListPage() {
function toggleDate(dateKey: string) {
setExpandedDates((prev) => {
const next = new Set(prev)
next.has(dateKey) ? next.delete(dateKey) : next.add(dateKey)
if (next.has(dateKey)) next.delete(dateKey); else next.add(dateKey)
return next
})
}
@@ -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>
+30 -5
View File
@@ -1,22 +1,27 @@
import { type FormEvent, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { createStaff, fetchStaffFormOptions, type RoleOption } from '../../api/staff'
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function StaffCreatePage() {
const navigate = useNavigate()
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const [roles, setRoles] = useState<RoleOption[]>([])
const [firstname, setFirstname] = useState('')
const [lastname, setLastname] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [roleId, setRoleId] = useState<number>(0)
const [status, setStatus] = useState('active')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await fetchStaffFormOptions()
const data = await fetchStaffFormOptions({ school_year: selectedSchoolYearName ?? undefined })
if (cancelled) return
const list = data.roles ?? []
setRoles(list)
@@ -28,17 +33,21 @@ export function StaffCreatePage() {
return () => {
cancelled = true
}
}, [])
}, [selectedSchoolYearName])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!selectedSchoolYearName) {
setError('Please select a school year.')
return
}
if (!roleId) {
setError('Please select a role.')
return
}
try {
await createStaff({ firstname, lastname, email, password, role_id: roleId })
navigate('/app/staff')
await createStaff({ firstname, lastname, email, phone, password, role_id: roleId, status, school_year: selectedSchoolYearName })
navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to create staff.')
}
@@ -47,6 +56,11 @@ export function StaffCreatePage() {
return (
<div className="container mt-4">
<h2>Add Staff Member</h2>
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="text-muted">School year: {selectedSchoolYearName ?? '—'}</div>
<SchoolYearSelector compact />
</div>
<ReadOnlyBanner />
{error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={(e) => void onSubmit(e)}>
<div className="mb-3">
@@ -89,6 +103,10 @@ export function StaffCreatePage() {
required
/>
</div>
<div className="mb-3">
<label className="form-label">Phone</label>
<input type="tel" className="form-control" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Role</label>
<select
@@ -104,7 +122,14 @@ export function StaffCreatePage() {
))}
</select>
</div>
<button type="submit" className="btn btn-success">
<div className="mb-3">
<label className="form-label">Status</label>
<select className="form-select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<button type="submit" className="btn btn-success" disabled={isReadOnlyYear || !selectedSchoolYearName}>
Create
</button>
</form>
+17 -5
View File
@@ -1,10 +1,13 @@
import { type FormEvent, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { fetchStaffMember, updateStaff } from '../../api/staff'
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function StaffEditPage() {
const { staffId } = useParams()
const navigate = useNavigate()
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const id = Number(staffId)
const [firstname, setFirstname] = useState('')
const [lastname, setLastname] = useState('')
@@ -17,7 +20,7 @@ export function StaffEditPage() {
let cancelled = false
;(async () => {
try {
const data = await fetchStaffMember(id)
const data = await fetchStaffMember(id, { school_year: selectedSchoolYearName ?? undefined })
if (cancelled) return
const u = (data.user ?? data.staff ?? {}) as Record<string, unknown>
setFirstname(String(u.firstname ?? ''))
@@ -32,14 +35,18 @@ export function StaffEditPage() {
return () => {
cancelled = true
}
}, [id])
}, [id, selectedSchoolYearName])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!id) return
try {
await updateStaff(id, { firstname, lastname, email, phone })
navigate('/app/staff')
if (!selectedSchoolYearName) {
setError('Please select a school year.')
return
}
await updateStaff(id, { firstname, lastname, email, phone, school_year: selectedSchoolYearName })
navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to update staff.')
}
@@ -48,6 +55,11 @@ export function StaffEditPage() {
return (
<div className="container mt-4">
<h2>Edit Staff Member</h2>
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="text-muted">School year: {selectedSchoolYearName ?? '—'}</div>
<SchoolYearSelector compact />
</div>
<ReadOnlyBanner />
{error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={(e) => void onSubmit(e)}>
<div className="mb-3">
@@ -91,7 +103,7 @@ export function StaffEditPage() {
placeholder="Enter phone number"
/>
</div>
<button type="submit" className="btn btn-primary">
<button type="submit" className="btn btn-primary" disabled={isReadOnlyYear || !selectedSchoolYearName}>
Update
</button>
</form>

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