Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6d2bc007e | |||
| b895c06dc1 | |||
| 4b78aafa65 | |||
| b510f534fd | |||
| b74ddca8b1 | |||
| 8cacd0874f | |||
| 405dc3e379 | |||
| 816f4cfaf0 | |||
| 7282020444 | |||
| 1992db65b8 | |||
| 32875e54a8 | |||
| 14eade0b7e | |||
| 1e2345323f | |||
| c3570481c8 | |||
| 09dbca4767 | |||
| 2eaf590373 | |||
| 4b6232d2ba | |||
| cd29394211 | |||
| 1eb45f46a8 | |||
| 665b26df1e | |||
| 7365c14506 | |||
| e8d1b8c1c7 | |||
| fe1c50878b | |||
| b6513ab22f | |||
| 8e79201a3c | |||
| c4d7a06a17 | |||
| 647b96cafc |
@@ -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` |
|
||||
@@ -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 }}/
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -19,5 +19,17 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// ── New strict rules from react-hooks v7 — downgrade to warn ──
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/preserve-manual-memoization': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
|
||||
// ── Keep these as errors (real bugs / anti-patterns) ──
|
||||
'react-hooks/static-components': 'error',
|
||||
'@typescript-eslint/no-unused-expressions': 'error',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+561
-550
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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": {}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { apiFetch } from './http'
|
||||
|
||||
export type ParentProfileSummary = {
|
||||
id: number
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
email?: string | null
|
||||
cellphone?: string | null
|
||||
is_active?: boolean
|
||||
families_count?: number
|
||||
selected_year_students_count?: number
|
||||
balance?: number
|
||||
positive_unpaid_balance?: number
|
||||
credit_balance?: number
|
||||
primary_family?: { id?: number; household_name?: string | null } | null
|
||||
}
|
||||
|
||||
export type ParentProfileDetail = {
|
||||
school_year: string
|
||||
read_only: boolean
|
||||
parent: Record<string, unknown> | null
|
||||
families: Record<string, unknown>[]
|
||||
students: Record<string, unknown>[]
|
||||
emergency_contacts: Record<string, unknown>[]
|
||||
invoices: Record<string, unknown>[]
|
||||
payments: Record<string, unknown>[]
|
||||
finance_summary: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Envelope<T> = { data?: T }
|
||||
|
||||
function unwrap<T>(response: T | Envelope<T>): T {
|
||||
if (response && typeof response === 'object' && 'data' in response) {
|
||||
return (response as Envelope<T>).data as T
|
||||
}
|
||||
return response as T
|
||||
}
|
||||
|
||||
export async function fetchParentProfiles(params: {
|
||||
schoolYear: string
|
||||
q?: string
|
||||
page?: number
|
||||
perPage?: number
|
||||
}): Promise<{
|
||||
school_year: string
|
||||
read_only: boolean
|
||||
parents: ParentProfileSummary[]
|
||||
pagination: { page: number; per_page: number; total: number }
|
||||
}> {
|
||||
const query = new URLSearchParams()
|
||||
query.set('school_year', params.schoolYear)
|
||||
if (params.q?.trim()) query.set('q', params.q.trim())
|
||||
if (params.page) query.set('page', String(params.page))
|
||||
if (params.perPage) query.set('per_page', String(params.perPage))
|
||||
return unwrap(await apiFetch(`/api/v1/parent-profiles?${query.toString()}`))
|
||||
}
|
||||
|
||||
export async function fetchParentProfile(parentId: number, schoolYear: string): Promise<ParentProfileDetail> {
|
||||
const query = new URLSearchParams({ school_year: schoolYear })
|
||||
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`))
|
||||
}
|
||||
|
||||
export async function updateParentProfile(parentId: number, schoolYear: string, payload: Record<string, unknown>): Promise<ParentProfileDetail> {
|
||||
const query = new URLSearchParams({ school_year: schoolYear })
|
||||
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, school_year: schoolYear }),
|
||||
}))
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
|
||||
*/
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||
|
||||
export type AdminNotificationRow = {
|
||||
id?: number
|
||||
@@ -56,6 +56,7 @@ async function postMultipart<T>(path: string, formData: FormData): Promise<T> {
|
||||
const headers = new Headers({ Accept: 'application/json' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
applyStoredSchoolYearHeaders(headers)
|
||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
@@ -136,6 +137,7 @@ export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string
|
||||
const headers = new Headers({ Accept: '*/*' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
applyStoredSchoolYearHeaders(headers)
|
||||
const res = await fetch(apiUrl(apiPath), { headers })
|
||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||
const blob = await res.blob()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+105
-25
@@ -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) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(url, { headers })
|
||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||
const raw = await res.blob()
|
||||
const blob = new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
|
||||
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
|
||||
if (!token) {
|
||||
throw new ApiHttpError('Your session has expired. Please sign in again.', 401, null)
|
||||
}
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
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()
|
||||
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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+391
-30
@@ -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,18 +96,110 @@ 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',
|
||||
const loginPaths = ['/api/v1/auth/login', '/api/v1/login', '/api/login']
|
||||
let body: unknown = null
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const path of loginPaths) {
|
||||
try {
|
||||
body = await apiFetch<unknown>(
|
||||
path,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -108,17 +207,33 @@ export async function loginRequest(
|
||||
},
|
||||
{ attachAuth: false },
|
||||
)
|
||||
|
||||
if (!body || typeof body !== 'object' || !('status' in body)) {
|
||||
throw new Error('Invalid login response from API.')
|
||||
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
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* Staff directory (legacy CI `staff/*`).
|
||||
* Expected Laravel routes under `/api/v1/administrator/staff`.
|
||||
* Staff directory.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/administrator/staff'
|
||||
const BASE = '/api/v1/staff'
|
||||
|
||||
function unwrapData<T>(body: T | { data?: T }): T {
|
||||
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
|
||||
return (body as { data?: T }).data as T
|
||||
}
|
||||
return body as T
|
||||
}
|
||||
|
||||
export type StaffListRow = {
|
||||
id: number
|
||||
@@ -17,6 +23,7 @@ export type StaffListRow = {
|
||||
active_role?: string | null
|
||||
class_section?: string | null
|
||||
verification_issue?: boolean
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
export type StaffIndexResponse = {
|
||||
@@ -25,12 +32,16 @@ export type StaffIndexResponse = {
|
||||
school_year?: string | null
|
||||
semester?: string | null
|
||||
schoolYears?: string[]
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
export type RoleOption = { id: number; name: string }
|
||||
export type RoleOption = { id: number; name: string; label?: string }
|
||||
|
||||
export type StaffFormOptionsResponse = {
|
||||
roles?: RoleOption[]
|
||||
statuses?: Array<{ value: string; label: string }>
|
||||
school_year?: string | null
|
||||
is_read_only_year?: boolean
|
||||
}
|
||||
|
||||
export type StaffMemberResponse = {
|
||||
@@ -52,28 +63,45 @@ export type StaffMemberResponse = {
|
||||
export async function fetchStaffIndex(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
role?: string
|
||||
status?: string
|
||||
search?: string
|
||||
page?: number
|
||||
per_page?: number
|
||||
}): Promise<StaffIndexResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
if (params?.role) qs.set('role', params.role)
|
||||
if (params?.status) qs.set('status', params.status)
|
||||
if (params?.search) qs.set('search', params.search)
|
||||
if (params?.page) qs.set('page', String(params.page))
|
||||
if (params?.per_page) qs.set('per_page', String(params.per_page))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch(`${BASE}${suffix}`)
|
||||
return unwrapData(await apiFetch(`${BASE}${suffix}`))
|
||||
}
|
||||
|
||||
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> {
|
||||
return apiFetch(`${BASE}/form-options`)
|
||||
export async function fetchStaffFormOptions(params?: { school_year?: string }): Promise<StaffFormOptionsResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
return unwrapData(await apiFetch(`${BASE}/form-options${qs.toString() ? `?${qs}` : ''}`))
|
||||
}
|
||||
|
||||
export async function fetchStaffMember(id: number): Promise<StaffMemberResponse> {
|
||||
return apiFetch(`${BASE}/${id}`)
|
||||
export async function fetchStaffMember(id: number, params?: { school_year?: string }): Promise<StaffMemberResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
return unwrapData(await apiFetch(`${BASE}/${id}${qs.toString() ? `?${qs}` : ''}`))
|
||||
}
|
||||
|
||||
export async function createStaff(payload: {
|
||||
firstname: string
|
||||
lastname: string
|
||||
email: string
|
||||
phone?: string
|
||||
password: string
|
||||
role_id: number
|
||||
status?: string
|
||||
school_year: string
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`${BASE}`, {
|
||||
method: 'POST',
|
||||
@@ -84,10 +112,10 @@ export async function createStaff(payload: {
|
||||
|
||||
export async function updateStaff(
|
||||
id: number,
|
||||
payload: { firstname: string; lastname: string; email: string; phone?: string },
|
||||
payload: { firstname: string; lastname: string; email: string; phone?: string; role_id?: number; status?: string; school_year: string },
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`${BASE}/${id}`, {
|
||||
method: 'PUT',
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Teacher portal API (`Views/teacher/*` parity).
|
||||
* Laravel should expose matching routes under `/api/v1/teacher/...`.
|
||||
*/
|
||||
import { apiFetch, getStoredToken } from './http'
|
||||
import { apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
|
||||
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
|
||||
@@ -47,6 +47,7 @@ export async function postTeacherMultipart<T>(path: string, formData: FormData):
|
||||
const headers = new Headers({ Accept: 'application/json' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
applyStoredSchoolYearHeaders(headers)
|
||||
|
||||
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ReadOnlyBanner } from './ReadOnlyBanner'
|
||||
export { ReadOnlyGuard } from './ReadOnlyGuard'
|
||||
export { SchoolYearSelector } from './SchoolYearSelector'
|
||||
export { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { fetchSchoolYears, type SchoolYearRecord } from '../api/schoolYears'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import {
|
||||
clearSelectedSchoolYear,
|
||||
getStoredSelectedSchoolYearName,
|
||||
isCurrentSchoolYear,
|
||||
isReadOnlySchoolYear,
|
||||
storeSelectedSchoolYear,
|
||||
} from '../lib/schoolYearSelection'
|
||||
|
||||
type SchoolYearContextValue = {
|
||||
schoolYears: SchoolYearRecord[]
|
||||
selectedSchoolYear: SchoolYearRecord | null
|
||||
selectedSchoolYearName: string | null
|
||||
activeSchoolYear: SchoolYearRecord | null
|
||||
isLoadingSchoolYears: boolean
|
||||
schoolYearError: string | null
|
||||
isReadOnlyYear: boolean
|
||||
setSelectedSchoolYearName: (name: string | null) => void
|
||||
refreshSchoolYears: () => Promise<void>
|
||||
}
|
||||
|
||||
const SchoolYearContext = createContext<SchoolYearContextValue | null>(null)
|
||||
|
||||
function yearNameFromSearchParams(searchParams: URLSearchParams): string | null {
|
||||
return (
|
||||
searchParams.get('school_year')?.trim() ||
|
||||
searchParams.get('schoolYear')?.trim() ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSelectedYear(params: {
|
||||
years: SchoolYearRecord[]
|
||||
searchParams: URLSearchParams
|
||||
requestedName: string | null
|
||||
}): SchoolYearRecord | null {
|
||||
const { years, searchParams, requestedName } = params
|
||||
if (years.length === 0) return null
|
||||
|
||||
const urlName = yearNameFromSearchParams(searchParams)
|
||||
const storedName = getStoredSelectedSchoolYearName()
|
||||
|
||||
const byRequestedName = requestedName ? years.find((year) => year.name === requestedName) : null
|
||||
if (byRequestedName) return byRequestedName
|
||||
|
||||
const byUrlName = urlName ? years.find((year) => year.name === urlName) : null
|
||||
if (byUrlName) return byUrlName
|
||||
|
||||
const byStoredName = storedName ? years.find((year) => year.name === storedName) : null
|
||||
if (byStoredName) return byStoredName
|
||||
|
||||
return years.find(isCurrentSchoolYear) ?? years[0] ?? null
|
||||
}
|
||||
|
||||
export function SchoolYearProvider({ children }: { children: ReactNode }) {
|
||||
const { token } = useAuth()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [schoolYears, setSchoolYears] = useState<SchoolYearRecord[]>([])
|
||||
const [requestedYearName, setRequestedYearName] = useState<string | null>(null)
|
||||
const [isLoadingSchoolYears, setIsLoadingSchoolYears] = useState(false)
|
||||
const [schoolYearError, setSchoolYearError] = useState<string | null>(null)
|
||||
|
||||
const refreshSchoolYears = useCallback(async () => {
|
||||
if (!token) {
|
||||
setSchoolYears([])
|
||||
setSchoolYearError(null)
|
||||
clearSelectedSchoolYear()
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoadingSchoolYears(true)
|
||||
setSchoolYearError(null)
|
||||
|
||||
try {
|
||||
const years = await fetchSchoolYears()
|
||||
setSchoolYears(years)
|
||||
} catch (error) {
|
||||
setSchoolYears([])
|
||||
setSchoolYearError(error instanceof Error ? error.message : 'Unable to load school years.')
|
||||
} finally {
|
||||
setIsLoadingSchoolYears(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSchoolYears()
|
||||
}, [refreshSchoolYears])
|
||||
|
||||
const selectedSchoolYear = useMemo(
|
||||
() =>
|
||||
resolveSelectedYear({
|
||||
years: schoolYears,
|
||||
searchParams,
|
||||
requestedName: requestedYearName,
|
||||
}),
|
||||
[requestedYearName, schoolYears, searchParams],
|
||||
)
|
||||
|
||||
const activeSchoolYear = useMemo(
|
||||
() => schoolYears.find(isCurrentSchoolYear) ?? null,
|
||||
[schoolYears],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
storeSelectedSchoolYear(selectedSchoolYear)
|
||||
|
||||
if (!selectedSchoolYear || !token) return
|
||||
|
||||
setSearchParams((current) => {
|
||||
const next = new URLSearchParams(current)
|
||||
const currentName = next.get('school_year')
|
||||
|
||||
next.delete('school_year_id')
|
||||
next.delete('year')
|
||||
|
||||
if (currentName === selectedSchoolYear.name && next.toString() === current.toString()) {
|
||||
return current
|
||||
}
|
||||
|
||||
next.set('school_year', selectedSchoolYear.name)
|
||||
return next
|
||||
}, { replace: true })
|
||||
}, [selectedSchoolYear, setSearchParams, token])
|
||||
|
||||
const value = useMemo<SchoolYearContextValue>(() => ({
|
||||
schoolYears,
|
||||
selectedSchoolYear,
|
||||
selectedSchoolYearName: selectedSchoolYear?.name ?? null,
|
||||
activeSchoolYear,
|
||||
isLoadingSchoolYears,
|
||||
schoolYearError,
|
||||
isReadOnlyYear: isReadOnlySchoolYear(selectedSchoolYear),
|
||||
setSelectedSchoolYearName: setRequestedYearName,
|
||||
refreshSchoolYears,
|
||||
}), [
|
||||
activeSchoolYear,
|
||||
isLoadingSchoolYears,
|
||||
refreshSchoolYears,
|
||||
schoolYearError,
|
||||
schoolYears,
|
||||
selectedSchoolYear,
|
||||
])
|
||||
|
||||
return <SchoolYearContext.Provider value={value}>{children}</SchoolYearContext.Provider>
|
||||
}
|
||||
|
||||
export function useSchoolYear(): SchoolYearContextValue {
|
||||
const context = useContext(SchoolYearContext)
|
||||
if (!context) {
|
||||
throw new Error('useSchoolYear must be used inside SchoolYearProvider.')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSchoolYear } from '../context/SchoolYearContext'
|
||||
|
||||
export function useSelectedSchoolYearParams() {
|
||||
const { selectedSchoolYear, selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
|
||||
|
||||
return useMemo(() => {
|
||||
const schoolYear = selectedSchoolYearName
|
||||
const query = new URLSearchParams()
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (schoolYear) {
|
||||
query.set('school_year', schoolYear)
|
||||
headers['X-School-Year'] = schoolYear
|
||||
}
|
||||
|
||||
const queryString = query.toString()
|
||||
|
||||
return {
|
||||
schoolYear,
|
||||
query,
|
||||
queryString,
|
||||
headers,
|
||||
isReadOnly: selectedSchoolYear ? isReadOnlyYear : false,
|
||||
}
|
||||
}, [isReadOnlyYear, selectedSchoolYear, selectedSchoolYearName])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import { CiPublicFooter } from '../components/CiPartials'
|
||||
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
|
||||
import { readPortalStylePrefs, resolvePortalColorMode, type PortalStylePrefs } from '../lib/portalAppearance'
|
||||
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
|
||||
|
||||
@@ -169,7 +170,6 @@ export function MainLayout() {
|
||||
<div className="container-fluid">
|
||||
<Link className="navbar-brand fw-semibold d-inline-flex align-items-center gap-2" to={dashboardPath}>
|
||||
<img className="main-layout-logo" src="/logo.png" alt="Al Rahma Sunday School logo" />
|
||||
Al Rahma Sunday School
|
||||
</Link>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
@@ -214,6 +214,11 @@ export function MainLayout() {
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
{user ? (
|
||||
<div className="d-none d-lg-flex align-items-center me-2">
|
||||
<SchoolYearSelector compact />
|
||||
</div>
|
||||
) : null}
|
||||
{user ? (
|
||||
<div
|
||||
ref={clusterRef}
|
||||
@@ -333,6 +338,7 @@ export function MainLayout() {
|
||||
</header>
|
||||
|
||||
<main className="container mt-4 flex-grow-1">
|
||||
<ReadOnlyBanner />
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { SchoolYearRecord } from '../api/schoolYears'
|
||||
|
||||
export const SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY = 'alrahma_selected_school_year_name'
|
||||
export const SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY = 'alrahma_selected_school_year_status'
|
||||
|
||||
function safeLocalStorage(): Storage | null {
|
||||
try {
|
||||
return typeof window !== 'undefined' ? window.localStorage : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentSchoolYear(year: Pick<SchoolYearRecord, 'is_current'> & { isCurrent?: boolean }): boolean {
|
||||
return Boolean(year.is_current || year.isCurrent)
|
||||
}
|
||||
|
||||
export function isReadOnlySchoolYearStatus(status: string | null | undefined): boolean {
|
||||
const normalized = String(status ?? '').trim().toLowerCase()
|
||||
return normalized === 'closed' || normalized === 'archived'
|
||||
}
|
||||
|
||||
export function isReadOnlySchoolYear(year: SchoolYearRecord | null | undefined): boolean {
|
||||
return year ? isReadOnlySchoolYearStatus(year.status) : false
|
||||
}
|
||||
|
||||
export function getStoredSelectedSchoolYearName(): string | null {
|
||||
const storage = safeLocalStorage()
|
||||
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)?.trim() ?? ''
|
||||
return value || null
|
||||
}
|
||||
|
||||
export function getStoredSelectedSchoolYearStatus(): string | null {
|
||||
const storage = safeLocalStorage()
|
||||
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)?.trim() ?? ''
|
||||
return value || null
|
||||
}
|
||||
|
||||
export function storeSelectedSchoolYear(year: SchoolYearRecord | null): void {
|
||||
const storage = safeLocalStorage()
|
||||
if (!storage) return
|
||||
|
||||
if (!year) {
|
||||
storage.removeItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)
|
||||
storage.removeItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
storage.setItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY, year.name)
|
||||
storage.setItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY, year.status)
|
||||
}
|
||||
|
||||
export function clearSelectedSchoolYear(): void {
|
||||
storeSelectedSchoolYear(null)
|
||||
}
|
||||
|
||||
export function selectedSchoolYearHeaders(): Record<string, string> {
|
||||
const name = getStoredSelectedSchoolYearName()
|
||||
const status = getStoredSelectedSchoolYearStatus()
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (name) {
|
||||
headers['X-School-Year'] = name
|
||||
}
|
||||
|
||||
if (status) {
|
||||
headers['X-School-Year-Status'] = status
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export function buildSchoolYearQuery(params: {
|
||||
schoolYearName?: string | null
|
||||
existing?: string | URLSearchParams
|
||||
}): string {
|
||||
const query = new URLSearchParams(params.existing)
|
||||
|
||||
const name = params.schoolYearName?.trim()
|
||||
if (name) {
|
||||
query.set('school_year', name)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
return value ? `?${value}` : ''
|
||||
}
|
||||
|
||||
export function withSelectedSchoolYearUrl(url: string): string {
|
||||
const name = getStoredSelectedSchoolYearName()
|
||||
if (!name) return url
|
||||
|
||||
const [base, hash = ''] = url.split('#', 2)
|
||||
const [path, rawQuery = ''] = base.split('?', 2)
|
||||
const query = new URLSearchParams(rawQuery)
|
||||
query.delete('school_year_id')
|
||||
query.delete('year')
|
||||
query.set('school_year', name)
|
||||
|
||||
const next = `${path}?${query.toString()}`
|
||||
return hash ? `${next}#${hash}` : next
|
||||
}
|
||||
@@ -37,6 +37,27 @@ type FlatContact = {
|
||||
phone: string
|
||||
}
|
||||
|
||||
type SortKey = keyof Omit<FlatContact, 'contactId' | 'parentId'>
|
||||
|
||||
function SortTh({ col, label, sortKey, sortDir, onSort }: {
|
||||
col: SortKey
|
||||
label: string
|
||||
sortKey: SortKey
|
||||
sortDir: 'asc' | 'desc'
|
||||
onSort: (col: SortKey) => void
|
||||
}) {
|
||||
const active = sortKey === col
|
||||
return (
|
||||
<th
|
||||
role="button"
|
||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
|
||||
onClick={() => onSort(col)}
|
||||
>
|
||||
{label} <span className="text-muted small">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminEmergencyContactIndexPage() {
|
||||
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -82,7 +103,7 @@ export function AdminEmergencyContactIndexPage() {
|
||||
|
||||
const displayed = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
let rows = q
|
||||
const rows = q
|
||||
? flatRows.filter((r) =>
|
||||
r.parentName.toLowerCase().includes(q) ||
|
||||
r.students.toLowerCase().includes(q) ||
|
||||
@@ -97,19 +118,6 @@ export function AdminEmergencyContactIndexPage() {
|
||||
})
|
||||
}, [flatRows, search, sortKey, sortDir])
|
||||
|
||||
function SortTh({ col, label }: { col: typeof sortKey; label: string }) {
|
||||
const active = sortKey === col
|
||||
return (
|
||||
<th
|
||||
role="button"
|
||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
|
||||
onClick={() => toggleSort(col)}
|
||||
>
|
||||
{label} <span className="text-muted small">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title="Emergency Contact Information">
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
@@ -132,11 +140,11 @@ export function AdminEmergencyContactIndexPage() {
|
||||
<table className="table table-bordered table-striped align-middle">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<SortTh col="parentName" label="Parent Name" />
|
||||
<SortTh col="students" label="Students" />
|
||||
<SortTh col="contactName" label="Contact Name" />
|
||||
<SortTh col="relation" label="Relation" />
|
||||
<SortTh col="phone" label="Phone" />
|
||||
<SortTh col="parentName" label="Parent Name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<SortTh col="students" label="Students" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<SortTh col="contactName" label="Contact Name" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<SortTh col="relation" label="Relation" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<SortTh col="phone" label="Phone" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -2,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,27 +4002,15 @@ 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 className="d-flex flex-wrap align-items-start justify-content-between gap-2 mb-3">
|
||||
<CiAcademicFilter
|
||||
selectedYear={schoolYear}
|
||||
onApply={({ schoolYear: year }) => void load(year)}
|
||||
/>
|
||||
</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">
|
||||
<Link className="btn btn-outline-primary" to={`/app/administrator/sections/auto-distribute${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
|
||||
Auto-distribute sections
|
||||
</Link>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,224 +1,176 @@
|
||||
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)
|
||||
function isoToday() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
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()}`
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
const selectAllRef = useRef<HTMLInputElement>(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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
|
||||
|
||||
{/* 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">
|
||||
<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">{students.length}</span>
|
||||
Students <span className="badge bg-secondary ms-1">{section.student_count}</span>
|
||||
</span>
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<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" />
|
||||
@@ -227,94 +179,241 @@ export function CertificatesPage() {
|
||||
type="date"
|
||||
className="form-control"
|
||||
value={certDate}
|
||||
onChange={(e) => setCertDate(e.target.value)}
|
||||
onChange={(event) => setCertDate(event.target.value)}
|
||||
title="Certificate date"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-check mb-0">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="selectAll"
|
||||
ref={selectAllRef}
|
||||
onChange={(e) => toggleAll(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="selectAll">
|
||||
Select all
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</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 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>Grade / Class</th>
|
||||
<th>Last Name</th>
|
||||
<th className="text-center">Year Score</th>
|
||||
<th>Decision</th>
|
||||
<th>Certificate No.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.id}>
|
||||
{section.students.map((student) => (
|
||||
<tr key={student.student_id}>
|
||||
<td className="text-center">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(s.id)}
|
||||
onChange={() => toggleStudent(s.id)}
|
||||
checked={selectedIds.includes(student.student_id)}
|
||||
disabled={!student.eligible || busy}
|
||||
onChange={() => toggleStudent(student.student_id)}
|
||||
/>
|
||||
</td>
|
||||
<td>{s.lastname}</td>
|
||||
<td>{s.firstname}</td>
|
||||
<td>{s.grade}</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>
|
||||
|
||||
<div className="card-footer d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex justify-content-between align-items-center mt-2">
|
||||
<span className="text-muted small">
|
||||
{selectedIds.size} student{selectedIds.size !== 1 ? 's' : ''} selected
|
||||
{selectedIds.length} student{selectedIds.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-success"
|
||||
disabled={selectedIds.size === 0 || generating}
|
||||
>
|
||||
{generating ? (
|
||||
<button type="submit" className="btn btn-success" disabled={selectedIds.length === 0 || busy}>
|
||||
{busy ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||
Generating…
|
||||
<span className="spinner-border spinner-border-sm me-1" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-printer me-1" />
|
||||
Generate & Print Certificates
|
||||
Generate & Print Certificates
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
{!loadingStudents && classId && !hasStudents && (
|
||||
<div className="alert alert-warning">
|
||||
No active students found for the selected class and school year.
|
||||
</div>
|
||||
)}
|
||||
export function CertificatesPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const groupKey = searchParams.get('group') ?? ''
|
||||
|
||||
{!loadingStudents && !classId && (
|
||||
<div className="alert alert-info">
|
||||
Select a class above to load its student roster.
|
||||
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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{visibleRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={12} className="text-muted small">
|
||||
Class rows load from the backend when competition settings APIs are available.
|
||||
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>
|
||||
<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> {data.competition.is_published ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
{Object.keys(winnersByClass).length === 0 ? (
|
||||
<div className="alert alert-warning">No published winners found for this competition.</div>
|
||||
<button type="button" className="btn btn-primary" disabled={publishing} onClick={() => void handlePublish()}>
|
||||
{publishing ? 'Publishing...' : 'Publish Winners'}
|
||||
</button>
|
||||
</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)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
async function load() {
|
||||
try {
|
||||
const [published, teacher] = await Promise.allSettled([
|
||||
fetchPublishedCompetitions(),
|
||||
fetchCompetitionScoresIndex(),
|
||||
])
|
||||
|
||||
if (published.status === 'fulfilled') {
|
||||
setPublishedRows(published.value.data?.competitions ?? [])
|
||||
const response = await fetchAdminCompetitionWinnersIndex()
|
||||
if (!response.ok) {
|
||||
setError(response.message ?? 'Unable to load competition winners data.')
|
||||
return
|
||||
}
|
||||
|
||||
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.')
|
||||
}
|
||||
setRows(response.competitions ?? [])
|
||||
setSectionMap(response.sectionMap ?? {})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to load competition winners data.')
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
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,30 +126,34 @@ 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">
|
||||
{id !== null ? (
|
||||
<>
|
||||
<Link
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
to={`${COMPETITION_WINNERS_BASE}/${id}/settings`}
|
||||
@@ -124,18 +175,26 @@ export function CompetitionWinnersIndexPage() {
|
||||
>
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ParentParityShell } from './ParentParityShell'
|
||||
import {
|
||||
fetchAttendanceReportForm,
|
||||
@@ -9,7 +10,9 @@ import {
|
||||
fetchParentRegistrationOverview,
|
||||
submitParentAttendanceReport,
|
||||
submitContactMessage,
|
||||
updateParentEnrollments,
|
||||
} from '../../api/session'
|
||||
import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears'
|
||||
import type {
|
||||
ParentAttendanceReportFormResponse,
|
||||
ParentEnrollmentStudent,
|
||||
@@ -18,6 +21,7 @@ import type {
|
||||
ParentRegistrationOverviewResponse,
|
||||
} from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthProvider'
|
||||
import { useSchoolYear } from '../../context/SchoolYearContext'
|
||||
|
||||
/** `Views/parent/contact.php` — POST /api/v1/contact */
|
||||
export function ParentContactPage() {
|
||||
@@ -152,21 +156,36 @@ export function ParentContactPage() {
|
||||
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
|
||||
export function ParentEnrollClassesPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
||||
const [year, setYear] = useState<string | null>(null)
|
||||
const [dates, setDates] = useState<{
|
||||
withdrawalDeadline?: string | null
|
||||
lastDayOfRegistration?: string | null
|
||||
schoolStartDate?: string | null
|
||||
}>({})
|
||||
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
|
||||
const [actions, setActions] = useState<Record<number, 'enroll' | 'withdraw'>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentEnrollments(null)
|
||||
const requestedYear = new URLSearchParams(window.location.search).get('school_year')
|
||||
const data = await fetchParentEnrollments(requestedYear)
|
||||
if (cancelled) return
|
||||
setSchoolYears(data.schoolYears ?? [])
|
||||
setYear(data.selectedYear ?? null)
|
||||
setDates({
|
||||
withdrawalDeadline: data.withdrawalDeadline,
|
||||
lastDayOfRegistration: data.lastDayOfRegistration,
|
||||
schoolStartDate: data.schoolStartDate,
|
||||
})
|
||||
setStudents(data.students ?? [])
|
||||
setActions({})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
|
||||
@@ -179,70 +198,344 @@ export function ParentEnrollClassesPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
let enrollable = 0
|
||||
let withdrawable = 0
|
||||
for (const student of students) {
|
||||
if (canEnroll(student)) enrollable += 1
|
||||
if (canWithdraw(student)) withdrawable += 1
|
||||
}
|
||||
return { enrollable, withdrawable }
|
||||
}, [students])
|
||||
|
||||
async function loadYear(nextYear: string | null) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
try {
|
||||
const data = await fetchParentEnrollments(nextYear)
|
||||
setSchoolYears(data.schoolYears ?? [])
|
||||
setYear(data.selectedYear ?? nextYear)
|
||||
setDates({
|
||||
withdrawalDeadline: data.withdrawalDeadline,
|
||||
lastDayOfRegistration: data.lastDayOfRegistration,
|
||||
schoolStartDate: data.schoolStartDate,
|
||||
})
|
||||
setStudents(data.students ?? [])
|
||||
setActions({})
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function setAction(studentId: number, action: 'enroll' | 'withdraw' | null) {
|
||||
setActions((current) => {
|
||||
const next = { ...current }
|
||||
if (action) next[studentId] = action
|
||||
else delete next[studentId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
try {
|
||||
const enroll = Object.entries(actions)
|
||||
.filter(([, action]) => action === 'enroll')
|
||||
.map(([id]) => Number(id))
|
||||
const withdraw = Object.entries(actions)
|
||||
.filter(([, action]) => action === 'withdraw')
|
||||
.map(([id]) => Number(id))
|
||||
if (enroll.length === 0 && withdraw.length === 0) {
|
||||
setError('Choose at least one enrollment or withdrawal action.')
|
||||
return
|
||||
}
|
||||
const result = await updateParentEnrollments({ enroll, withdraw })
|
||||
const enrolledCount = result.enrolled?.length ?? 0
|
||||
const withdrawnCount = result.withdrawn?.length ?? 0
|
||||
await loadYear(year)
|
||||
setMessage(`Saved ${enrolledCount} enrollment request(s) and ${withdrawnCount} withdrawal request(s).`)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not save enrollment changes.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Enroll in classes"
|
||||
ciViewFile="enroll_classes.php"
|
||||
legacyCiRoutes={['/parent/enroll_classes']}
|
||||
>
|
||||
{message ? <div className="alert alert-success small">{message}</div> : null}
|
||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="small text-muted mb-2">
|
||||
School year: <code>{year ?? '—'}</code>. Updates use{' '}
|
||||
<code>POST /api/v1/parents/enrollments</code> with enroll / withdraw payloads.
|
||||
</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="row g-3 align-items-end mb-3">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="enrollment_school_year">
|
||||
School Year
|
||||
</label>
|
||||
<select
|
||||
id="enrollment_school_year"
|
||||
className="form-select"
|
||||
value={year ?? ''}
|
||||
onChange={(e) => void loadYear(e.target.value || null)}
|
||||
>
|
||||
{year && !schoolYears.some((item) => item.school_year === year) ? (
|
||||
<option value={year}>{year}</option>
|
||||
) : null}
|
||||
{schoolYears.map((item) => (
|
||||
<option key={item.school_year} value={item.school_year}>
|
||||
{item.school_year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-8">
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-success"
|
||||
onClick={() => {
|
||||
const next: Record<number, 'enroll' | 'withdraw'> = { ...actions }
|
||||
students.forEach((student) => {
|
||||
if (canEnroll(student)) next[student.id] = 'enroll'
|
||||
})
|
||||
setActions(next)
|
||||
}}
|
||||
disabled={counts.enrollable === 0 || saving}
|
||||
>
|
||||
Enroll all available
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger"
|
||||
onClick={() => {
|
||||
const next: Record<number, 'enroll' | 'withdraw'> = { ...actions }
|
||||
students.forEach((student) => {
|
||||
if (canWithdraw(student)) next[student.id] = 'withdraw'
|
||||
})
|
||||
setActions(next)
|
||||
}}
|
||||
disabled={counts.withdrawable === 0 || saving}
|
||||
>
|
||||
Withdraw all eligible
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => setActions({})}
|
||||
disabled={Object.keys(actions).length === 0 || saving}
|
||||
>
|
||||
Reset choices
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-md-4">
|
||||
<div className="border rounded p-3 h-100">
|
||||
<div className="small text-muted">Last day of registration</div>
|
||||
<div className="fw-semibold">{formatDate(dates.lastDayOfRegistration)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="border rounded p-3 h-100">
|
||||
<div className="small text-muted">Withdrawal deadline</div>
|
||||
<div className="fw-semibold">{formatDate(dates.withdrawalDeadline)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="border rounded p-3 h-100">
|
||||
<div className="small text-muted">School start date</div>
|
||||
<div className="fw-semibold">{formatDate(dates.schoolStartDate)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-bordered">
|
||||
<table className="table table-sm table-bordered align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Class</th>
|
||||
<th>Status</th>
|
||||
<th className="text-center">Enroll</th>
|
||||
<th className="text-center">Withdraw</th>
|
||||
<th>Choice</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={2} className="text-muted">
|
||||
<td colSpan={6} className="text-muted">
|
||||
No students for this overview.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
students.map((s) => (
|
||||
<tr key={s.id}>
|
||||
students.map((student) => {
|
||||
const selected = actions[student.id] ?? null
|
||||
const enrollEnabled = canEnroll(student)
|
||||
const withdrawEnabled = canWithdraw(student)
|
||||
return (
|
||||
<tr key={student.id}>
|
||||
<td>
|
||||
{s.firstname} {s.lastname}
|
||||
<div className="fw-semibold">
|
||||
{student.firstname} {student.lastname}
|
||||
</div>
|
||||
<div className="small text-muted">
|
||||
{student.registration_grade ? `Grade ${student.registration_grade}` : 'Grade not set'}
|
||||
{student.gender ? ` · ${student.gender}` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>{s.class_section ?? '—'}</td>
|
||||
<td>{student.class_section ?? 'Class not Assigned'}</td>
|
||||
<td>{statusBadge(student.enrollment_status ?? student.admission_status)}</td>
|
||||
<td className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${selected === 'enroll' ? 'btn-success' : 'btn-outline-success'}`}
|
||||
onClick={() => setAction(student.id, selected === 'enroll' ? null : 'enroll')}
|
||||
disabled={!enrollEnabled || saving}
|
||||
>
|
||||
Enroll
|
||||
</button>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${selected === 'withdraw' ? 'btn-danger' : 'btn-outline-danger'}`}
|
||||
onClick={() => setAction(student.id, selected === 'withdraw' ? null : 'withdraw')}
|
||||
disabled={!withdrawEnabled || saving}
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
</td>
|
||||
<td>{choiceBadge(selected)}</td>
|
||||
</tr>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{schoolYears.length > 1 ? (
|
||||
<p className="small mb-0">Additional years in response: {schoolYears.length}</p>
|
||||
) : null}
|
||||
</>
|
||||
<div className="d-flex flex-wrap gap-2 justify-content-end mt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => void loadYear(year)}
|
||||
disabled={saving}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="submit" className="btn btn-success" disabled={saving || Object.keys(actions).length === 0}>
|
||||
{saving ? 'Saving…' : 'Submit changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizedStatus(student: ParentEnrollmentStudent): string {
|
||||
return String(student.enrollment_status ?? student.admission_status ?? 'not enrolled').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function canEnroll(student: ParentEnrollmentStudent): boolean {
|
||||
if (student.disable_enroll) return false
|
||||
return ['not enrolled', 'withdrawn', 'refund pending'].includes(normalizedStatus(student))
|
||||
}
|
||||
|
||||
function canWithdraw(student: ParentEnrollmentStudent): boolean {
|
||||
return ['admission under review', 'payment pending', 'enrolled', 'waitlist'].includes(normalizedStatus(student))
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return 'Not set'
|
||||
return value
|
||||
}
|
||||
|
||||
function statusBadge(status?: string | null) {
|
||||
const value = String(status ?? 'not enrolled').trim() || 'not enrolled'
|
||||
const normalized = value.toLowerCase()
|
||||
let className = 'bg-secondary'
|
||||
if (normalized === 'enrolled') className = 'bg-success'
|
||||
else if (normalized === 'payment pending') className = 'bg-info text-dark'
|
||||
else if (normalized === 'admission under review' || normalized === 'waitlist') className = 'bg-warning text-dark'
|
||||
else if (normalized === 'withdraw under review' || normalized === 'refund pending') className = 'bg-warning text-dark'
|
||||
else if (normalized === 'withdrawn' || normalized === 'denied') className = 'bg-danger'
|
||||
return <span className={`badge ${className}`}>{value}</span>
|
||||
}
|
||||
|
||||
function choiceBadge(action: 'enroll' | 'withdraw' | null) {
|
||||
if (action === 'enroll') return <span className="badge bg-success">Enroll requested</span>
|
||||
if (action === 'withdraw') return <span className="badge bg-danger">Withdraw requested</span>
|
||||
return <span className="text-muted small">No change</span>
|
||||
}
|
||||
|
||||
function formatCurrency(value: number | null | undefined): string {
|
||||
return Number(value ?? 0).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
}
|
||||
|
||||
function parseCurrency(value: number | string | null | undefined): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value !== 'string') return 0
|
||||
const parsed = Number(value.replace(/[$,]/g, ''))
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function invoiceStatusClass(status?: string | null): string {
|
||||
const normalized = String(status ?? '').trim().toLowerCase()
|
||||
if (normalized === 'paid') return 'bg-success-subtle text-success-emphasis border border-success-subtle'
|
||||
if (normalized.includes('partial')) return 'bg-warning-subtle text-warning-emphasis border border-warning-subtle'
|
||||
if (normalized === 'pending' || normalized === 'unpaid') return 'bg-danger-subtle text-danger-emphasis border border-danger-subtle'
|
||||
return 'bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle'
|
||||
}
|
||||
|
||||
function paymentTone(balance: number): string {
|
||||
if (balance > 0) return 'text-danger'
|
||||
if (balance < 0) return 'text-success'
|
||||
return 'text-success'
|
||||
}
|
||||
|
||||
function formatDateLabel(value?: string | null): string {
|
||||
if (!value) return 'No due date'
|
||||
const date = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
|
||||
export function ParentPaymentInvoicesPage() {
|
||||
const { selectedSchoolYearName } = useSchoolYear()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [statementError, setStatementError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statementLoading, setStatementLoading] = useState(false)
|
||||
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
|
||||
const [statement, setStatement] = useState<ParentStatement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSchoolYearName) return
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchParentInvoices(null)
|
||||
const data = await fetchParentInvoices(selectedSchoolYearName)
|
||||
if (cancelled) return
|
||||
setRows(data.invoices ?? [])
|
||||
setError(null)
|
||||
@@ -255,53 +548,287 @@ export function ParentPaymentInvoicesPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [selectedSchoolYearName])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSchoolYearName) return
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setStatementLoading(true)
|
||||
setStatement(null)
|
||||
try {
|
||||
const data = await fetchParentStatement({ schoolYear: selectedSchoolYearName })
|
||||
if (cancelled) return
|
||||
setStatement(data)
|
||||
setStatementError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setStatement(null)
|
||||
setStatementError(e instanceof Error ? e.message : 'Failed to load statement.')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setStatementLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedSchoolYearName])
|
||||
|
||||
const statementLines = useMemo(() => statement?.lines ?? [], [statement])
|
||||
const totalBalance = useMemo(
|
||||
() => rows.reduce((sum, inv) => sum + parseCurrency(inv.balance), 0),
|
||||
[rows],
|
||||
)
|
||||
const totalPaid = useMemo(
|
||||
() => rows.reduce((sum, inv) => sum + parseCurrency(inv.paid_amount), 0),
|
||||
[rows],
|
||||
)
|
||||
const openInvoices = useMemo(
|
||||
() => rows.filter((inv) => parseCurrency(inv.balance) > 0),
|
||||
[rows],
|
||||
)
|
||||
const paidInvoices = rows.length - openInvoices.length
|
||||
const nextDueInvoice = useMemo(() => {
|
||||
return [...openInvoices]
|
||||
.filter((inv) => inv.due_date)
|
||||
.sort((a, b) => String(a.due_date).localeCompare(String(b.due_date)))[0]
|
||||
}, [openInvoices])
|
||||
const statementTotal = useMemo(
|
||||
() => statementLines.reduce((sum, line) => sum + parseCurrency(line.amount), 0),
|
||||
[statementLines],
|
||||
)
|
||||
|
||||
return (
|
||||
<ParentParityShell
|
||||
title="Payments & invoices"
|
||||
ciViewFile="payment_view.php"
|
||||
legacyCiRoutes={['/parent/payment']}
|
||||
fullWidth
|
||||
>
|
||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||
<div>
|
||||
<div className="text-uppercase text-success fw-semibold small mb-1">Family account</div>
|
||||
<h3 className="h4 mb-1">Payment overview</h3>
|
||||
<p className="text-muted mb-0">
|
||||
{selectedSchoolYearName ? `School year ${selectedSchoolYearName}` : 'School year not selected'}
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn btn-success" to="/app/parent/payment-redirect">
|
||||
Make a payment
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-4">
|
||||
<div className="border rounded-3 p-3 h-100 bg-white">
|
||||
<div className="small text-muted">Current balance</div>
|
||||
<div className={`display-6 fw-semibold ${paymentTone(statement?.current_balance ?? totalBalance)}`}>
|
||||
${formatCurrency(statement?.current_balance ?? totalBalance)}
|
||||
</div>
|
||||
<div className="small text-muted">Across all visible invoices and statement items</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="border rounded-3 p-3 h-100 bg-white">
|
||||
<div className="small text-muted">Invoices</div>
|
||||
<div className="display-6 fw-semibold text-dark">{rows.length}</div>
|
||||
<div className="small text-muted">
|
||||
{openInvoices.length} open, {paidInvoices} paid · ${formatCurrency(totalPaid)} received
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="border rounded-3 p-3 h-100 bg-white">
|
||||
<div className="small text-muted">Next due date</div>
|
||||
<div className="h3 fw-semibold mb-1">{formatDateLabel(nextDueInvoice?.due_date)}</div>
|
||||
<div className="small text-muted">
|
||||
{nextDueInvoice ? nextDueInvoice.invoice_number ?? `Invoice #${nextDueInvoice.id}` : 'No open due dates'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mb-4">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h3 className="h5 mb-1">Invoices</h3>
|
||||
<p className="text-muted small mb-0">Review balances and payment status for each invoice.</p>
|
||||
</div>
|
||||
<span className="badge bg-light text-dark border">${formatCurrency(totalBalance)} visible balance</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted small">Loading…</p>
|
||||
<div className="border rounded-3 p-4 text-muted small bg-white">Loading invoices…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="border rounded-3 p-4 text-center bg-white">
|
||||
<div className="fw-semibold">No invoices found</div>
|
||||
<div className="text-muted small">There are no invoices for this school year.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-grid gap-3">
|
||||
{rows.map((inv) => {
|
||||
const balance = parseCurrency(inv.balance)
|
||||
const totalAmount = parseCurrency(inv.total_amount)
|
||||
const paidAmount = parseCurrency(inv.paid_amount)
|
||||
const invoiceLabel = inv.invoice_number ?? `Invoice #${inv.id}`
|
||||
const payments = inv.payments ?? []
|
||||
return (
|
||||
<div className="border rounded-3 p-3 bg-white" key={inv.id}>
|
||||
<div className="row g-3 align-items-center">
|
||||
<div className="col-lg-3">
|
||||
<div className="small text-muted">Invoice</div>
|
||||
<div className="fw-semibold">{invoiceLabel}</div>
|
||||
<div className="small text-muted">Record #{inv.id}</div>
|
||||
</div>
|
||||
<div className="col-6 col-lg-2">
|
||||
<div className="small text-muted">Invoice total</div>
|
||||
<div className="h5 mb-0 text-dark">${formatCurrency(totalAmount)}</div>
|
||||
</div>
|
||||
<div className="col-6 col-lg-2">
|
||||
<div className="small text-muted">Paid</div>
|
||||
<div className="h5 mb-0 text-success">${formatCurrency(paidAmount)}</div>
|
||||
</div>
|
||||
<div className="col-6 col-lg-2">
|
||||
<div className="small text-muted">Balance</div>
|
||||
<div className={`h5 mb-0 ${paymentTone(balance)}`}>${formatCurrency(balance)}</div>
|
||||
</div>
|
||||
<div className="col-6 col-lg-1">
|
||||
<div className="small text-muted">Due</div>
|
||||
<div className="fw-semibold">{formatDateLabel(inv.due_date)}</div>
|
||||
</div>
|
||||
<div className="col-md-6 col-lg-1">
|
||||
<span className={`badge rounded-pill px-3 py-2 ${invoiceStatusClass(inv.status)}`}>
|
||||
{inv.status ?? 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-md-6 col-lg-1 text-md-end">
|
||||
{balance > 0 ? (
|
||||
<Link
|
||||
className="btn btn-outline-success btn-sm"
|
||||
to={`/app/parent/payment-redirect?invoice_id=${encodeURIComponent(String(inv.id))}`}
|
||||
>
|
||||
Pay invoice
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-success small fw-semibold">Paid</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-top mt-3 pt-3">
|
||||
<div className="small fw-semibold mb-2">Payments from payment records</div>
|
||||
{payments.length === 0 ? (
|
||||
<div className="small text-muted">No posted payments found for this invoice.</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped">
|
||||
<table className="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Invoice</th>
|
||||
<th>Balance</th>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
<th>Due</th>
|
||||
<th>Transaction</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id}>
|
||||
<td>{payment.payment_date ? formatDateLabel(payment.payment_date) : 'No payment date'}</td>
|
||||
<td>${formatCurrency(payment.paid_amount)}</td>
|
||||
<td>{payment.payment_method ?? '—'}</td>
|
||||
<td>{payment.status ?? '—'}</td>
|
||||
<td>{payment.transaction_id ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="border-top mt-4 pt-4">
|
||||
<div className="d-flex flex-wrap justify-content-between gap-3 mb-3">
|
||||
<div>
|
||||
<h3 className="h5 mb-1">Parent statement</h3>
|
||||
<p className="text-muted small mb-0">
|
||||
Opening balances from closed years are shown separately from new-year charges so they are not double-counted.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-md-end">
|
||||
<div className="small text-muted">Statement total</div>
|
||||
<div className="h4 mb-0">${formatCurrency(statementTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statementError ? <div className="alert alert-warning small">{statementError}</div> : null}
|
||||
{statementLoading ? (
|
||||
<div className="border rounded-3 p-4 text-muted small bg-white">Loading statement…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-md-6">
|
||||
<div className="border rounded-3 p-3 bg-light h-100">
|
||||
<div className="small text-muted">Opening balance from prior year</div>
|
||||
<div className="h4 mb-1">${formatCurrency(statement?.opening_balance)}</div>
|
||||
<div className="small text-muted">
|
||||
The old-balance invoice is the collectible item. This label is audit context, not a second charge.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="border rounded-3 p-3 bg-light h-100">
|
||||
<div className="small text-muted">Current balance</div>
|
||||
<div className={`h4 mb-1 ${paymentTone(statement?.current_balance ?? 0)}`}>
|
||||
${formatCurrency(statement?.current_balance)}
|
||||
</div>
|
||||
<div className="small text-muted">Includes current statement activity for this account.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm align-middle bg-white border">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colSpan={5} className="text-muted">
|
||||
No invoices.
|
||||
<th>Description</th>
|
||||
<th>Source year</th>
|
||||
<th>Invoice</th>
|
||||
<th className="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statementLines.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-muted">
|
||||
No statement lines found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((inv) => (
|
||||
<tr key={inv.id}>
|
||||
<td>{inv.id}</td>
|
||||
<td>{inv.invoice_number ?? '—'}</td>
|
||||
<td>
|
||||
{typeof inv.balance === 'number' ? inv.balance.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td>{inv.status ?? '—'}</td>
|
||||
<td>{inv.due_date ?? '—'}</td>
|
||||
statementLines.map((line, index) => (
|
||||
<tr key={`${line.label}-${line.invoice_id ?? index}`}>
|
||||
<td>{line.label}</td>
|
||||
<td>{line.source_school_year ?? '—'}</td>
|
||||
<td>{line.invoice_id ?? '—'}</td>
|
||||
<td className={`text-end ${paymentTone(line.amount)}`}>${formatCurrency(line.amount)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</ParentParityShell>
|
||||
)
|
||||
}
|
||||
@@ -515,7 +1042,7 @@ export function ParentReportAttendancePage() {
|
||||
<select name="student_id" className="form-control" required>
|
||||
<option value="">Select student</option>
|
||||
{Array.isArray(data?.students)
|
||||
? data.students.map((student: any) => (
|
||||
? data.students.map((student: { id: number; firstname: string; lastname: string }) => (
|
||||
<option key={student.id} value={student.id}>
|
||||
{student.firstname} {student.lastname}
|
||||
</option>
|
||||
|
||||
@@ -1,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>
|
||||
|
||||
@@ -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}
|
||||
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))
|
||||
})()}
|
||||
))}
|
||||
</select>
|
||||
</form>
|
||||
<div className="d-flex align-items-center gap-2 flex-nowrap flex-shrink-0">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
@@ -49,23 +49,32 @@ export function ManualPayPage() {
|
||||
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editPaymentId, setEditPaymentId] = useState<number | string | null>(null)
|
||||
const loadSeq = useRef(0)
|
||||
|
||||
const activeTerm = termFromUrl
|
||||
|
||||
const load = useCallback(
|
||||
(term: string) => {
|
||||
const requestId = ++loadSeq.current
|
||||
if (!term.trim()) {
|
||||
setData(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchManualPayPage({ search_term: term.trim() })
|
||||
.then(setData)
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.'),
|
||||
)
|
||||
.finally(() => setLoading(false))
|
||||
.then((payload) => {
|
||||
if (requestId === loadSeq.current) setData(payload)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (requestId === loadSeq.current) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === loadSeq.current) setLoading(false)
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
@@ -76,12 +85,11 @@ export function ManualPayPage() {
|
||||
}, [termFromUrl, load])
|
||||
|
||||
useEffect(() => {
|
||||
let t: ReturnType<typeof setTimeout>
|
||||
if (searchInput.trim().length < 2) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
t = setTimeout(() => {
|
||||
const t = setTimeout(() => {
|
||||
fetchManualPaySuggest(searchInput.trim())
|
||||
.then((rows) => {
|
||||
setSuggestions(
|
||||
@@ -104,6 +112,21 @@ export function ManualPayPage() {
|
||||
navigate({ pathname: '/app/administrator/payment/manual-pay', search: `?search_term=${encodeURIComponent(q)}` })
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
loadSeq.current += 1
|
||||
setSearchInput('')
|
||||
setData(null)
|
||||
setError(null)
|
||||
setSuggestions([])
|
||||
setSuggestOpen(false)
|
||||
setLoading(false)
|
||||
setShowAddModal(false)
|
||||
setEditPaymentId(null)
|
||||
if (previewBlobUrl) URL.revokeObjectURL(previewBlobUrl)
|
||||
setPreviewBlobUrl(null)
|
||||
navigate({ pathname: '/app/administrator/payment/manual-pay', search: '' })
|
||||
}
|
||||
|
||||
function pickSuggestion(text: string) {
|
||||
setSearchInput(text)
|
||||
setSuggestOpen(false)
|
||||
@@ -184,6 +207,14 @@ export function ManualPayPage() {
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
disabled={loading && !searchInput && !data}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{suggestOpen && suggestions.length > 0 ? (
|
||||
<div className="list-group position-absolute w-100 shadow-sm" style={{ zIndex: 10 }}>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function AdminPrintRequestsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm p-0"
|
||||
onClick={() => openPrintRequestFile(encodeURIComponent(fp))}
|
||||
onClick={() => openPrintRequestFile(id as number | string)}
|
||||
>
|
||||
{fp}
|
||||
</button>
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
fetchReportCardCompleteness,
|
||||
fetchReportCardMeta,
|
||||
fetchReportCardStudentHtml,
|
||||
openReportCardDownload,
|
||||
openReportCardClassDownload,
|
||||
openReportCardStudentDownload,
|
||||
reportCardClassUrl,
|
||||
reportCardStudentUrl,
|
||||
type ReportCardMeta,
|
||||
@@ -118,14 +119,12 @@ export function ReportCardManagementPage() {
|
||||
|
||||
async function downloadStudent() {
|
||||
if (!studentId || !schoolYear) return
|
||||
const url = reportCardStudentUrl(studentId, {
|
||||
try {
|
||||
await openReportCardStudentDownload(studentId, {
|
||||
school_year: schoolYear,
|
||||
semester,
|
||||
download: true,
|
||||
report_date: reportDate || undefined,
|
||||
})
|
||||
try {
|
||||
await openReportCardDownload(url)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
||||
}
|
||||
@@ -133,14 +132,12 @@ export function ReportCardManagementPage() {
|
||||
|
||||
async function downloadClass() {
|
||||
if (!classId || !schoolYear) return
|
||||
const url = reportCardClassUrl(classId, {
|
||||
try {
|
||||
await openReportCardClassDownload(classId, {
|
||||
school_year: schoolYear,
|
||||
semester,
|
||||
download: true,
|
||||
report_date: reportDate || undefined,
|
||||
})
|
||||
try {
|
||||
await openReportCardDownload(url)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof ApiHttpError ? e.message : 'Download failed.')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { createStaff, fetchStaffFormOptions, type RoleOption } from '../../api/staff'
|
||||
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
|
||||
import { useSchoolYear } from '../../context/SchoolYearContext'
|
||||
|
||||
export function StaffCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
|
||||
const [roles, setRoles] = useState<RoleOption[]>([])
|
||||
const [firstname, setFirstname] = useState('')
|
||||
const [lastname, setLastname] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [roleId, setRoleId] = useState<number>(0)
|
||||
const [status, setStatus] = useState('active')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await fetchStaffFormOptions()
|
||||
const data = await fetchStaffFormOptions({ school_year: selectedSchoolYearName ?? undefined })
|
||||
if (cancelled) return
|
||||
const list = data.roles ?? []
|
||||
setRoles(list)
|
||||
@@ -28,17 +33,21 @@ export function StaffCreatePage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [selectedSchoolYearName])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selectedSchoolYearName) {
|
||||
setError('Please select a school year.')
|
||||
return
|
||||
}
|
||||
if (!roleId) {
|
||||
setError('Please select a role.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createStaff({ firstname, lastname, email, password, role_id: roleId })
|
||||
navigate('/app/staff')
|
||||
await createStaff({ firstname, lastname, email, phone, password, role_id: roleId, status, school_year: selectedSchoolYearName })
|
||||
navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to create staff.')
|
||||
}
|
||||
@@ -47,6 +56,11 @@ export function StaffCreatePage() {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h2>Add Staff Member</h2>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<div className="text-muted">School year: {selectedSchoolYearName ?? '—'}</div>
|
||||
<SchoolYearSelector compact />
|
||||
</div>
|
||||
<ReadOnlyBanner />
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<form onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="mb-3">
|
||||
@@ -89,6 +103,10 @@ export function StaffCreatePage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Phone</label>
|
||||
<input type="tel" className="form-control" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Role</label>
|
||||
<select
|
||||
@@ -104,7 +122,14 @@ export function StaffCreatePage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-success">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Status</label>
|
||||
<select className="form-select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-success" disabled={isReadOnlyYear || !selectedSchoolYearName}>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { fetchStaffMember, updateStaff } from '../../api/staff'
|
||||
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
|
||||
import { useSchoolYear } from '../../context/SchoolYearContext'
|
||||
|
||||
export function StaffEditPage() {
|
||||
const { staffId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
|
||||
const id = Number(staffId)
|
||||
const [firstname, setFirstname] = useState('')
|
||||
const [lastname, setLastname] = useState('')
|
||||
@@ -17,7 +20,7 @@ export function StaffEditPage() {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await fetchStaffMember(id)
|
||||
const data = await fetchStaffMember(id, { school_year: selectedSchoolYearName ?? undefined })
|
||||
if (cancelled) return
|
||||
const u = (data.user ?? data.staff ?? {}) as Record<string, unknown>
|
||||
setFirstname(String(u.firstname ?? ''))
|
||||
@@ -32,14 +35,18 @@ export function StaffEditPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id])
|
||||
}, [id, selectedSchoolYearName])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!id) return
|
||||
try {
|
||||
await updateStaff(id, { firstname, lastname, email, phone })
|
||||
navigate('/app/staff')
|
||||
if (!selectedSchoolYearName) {
|
||||
setError('Please select a school year.')
|
||||
return
|
||||
}
|
||||
await updateStaff(id, { firstname, lastname, email, phone, school_year: selectedSchoolYearName })
|
||||
navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to update staff.')
|
||||
}
|
||||
@@ -48,6 +55,11 @@ export function StaffEditPage() {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<h2>Edit Staff Member</h2>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<div className="text-muted">School year: {selectedSchoolYearName ?? '—'}</div>
|
||||
<SchoolYearSelector compact />
|
||||
</div>
|
||||
<ReadOnlyBanner />
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<form onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="mb-3">
|
||||
@@ -91,7 +103,7 @@ export function StaffEditPage() {
|
||||
placeholder="Enter phone number"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<button type="submit" className="btn btn-primary" disabled={isReadOnlyYear || !selectedSchoolYearName}>
|
||||
Update
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user