Compare commits
29 Commits
23826c5528
...
c3570481c8
| Author | SHA1 | Date | |
|---|---|---|---|
| c3570481c8 | |||
| 09dbca4767 | |||
| 2eaf590373 | |||
| 4b6232d2ba | |||
| cd29394211 | |||
| 1eb45f46a8 | |||
| 665b26df1e | |||
| 7365c14506 | |||
| e8d1b8c1c7 | |||
| fe1c50878b | |||
| b6513ab22f | |||
| 8e79201a3c | |||
| c4d7a06a17 | |||
| 647b96cafc | |||
| dec023f03e | |||
| 1b0d9a36ad | |||
| ad7b13f4d8 | |||
| 1af02dadd3 | |||
| 4dde38004f | |||
| 988d04c0a6 | |||
| 0d3cdd3857 | |||
| dc7acf2536 | |||
| ea495f8382 | |||
| 0687d4677f | |||
| 93b75b9b3f | |||
| 3e77fc92c7 | |||
| 7fe34dde0d | |||
| 94700d4f0f | |||
| 9191fd32f0 |
@@ -0,0 +1,49 @@
|
||||
# Gitea CI/CD — Setup Checklist
|
||||
|
||||
After pushing, the pipeline will run automatically. The following steps are required to unlock all features (especially deploy jobs).
|
||||
|
||||
## 1. Repository Secrets (Settings → Actions → Secrets)
|
||||
|
||||
| Secret Name | Description |
|
||||
|---|---|
|
||||
| `SSH_PASSWORD` | SSH password for the deployment server |
|
||||
| `SSH_USER` | SSH username for the deployment server |
|
||||
| `SSH_HOST` | IP or domain of the deployment server |
|
||||
|
||||
## 2. Repository Variables (Settings → Actions → Variables)
|
||||
|
||||
| Variable Name | Default | Description |
|
||||
|---|---|---|
|
||||
| `SSH_PORT` | `22` | SSH port for the deployment server |
|
||||
| `SSH_TARGET_DIR` | — | Absolute path on the server where `dist/` contents should be placed |
|
||||
| `PRODUCTION_URL` | — | Public URL of the production site |
|
||||
| `VITE_API_ORIGIN` | — | (Optional) API base URL passed at build time |
|
||||
|
||||
## 3. Allow Gitea Actions
|
||||
|
||||
- Go to **Settings → Actions** in your Gitea repository
|
||||
- Ensure **"Actions"** are enabled
|
||||
- If using self-hosted runners, ensure a runner is registered and online
|
||||
|
||||
## 4. Optional: Configure Gitea Actions Runner
|
||||
|
||||
If no runner is registered yet on your Gitea instance:
|
||||
|
||||
```bash
|
||||
# On the runner machine (Docker-based)
|
||||
docker run -d \
|
||||
--name gitea-runner \
|
||||
-e GITEA_INSTANCE_URL=https://192.168.3.80 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<your-registration-token> \
|
||||
-e GITEA_RUNNER_NAME=runner-1 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
## Pipeline Stages
|
||||
|
||||
| Job | Requires Secrets/Vars | Runs on |
|
||||
|---|---|---|
|
||||
| `lint` | None | Push & PR |
|
||||
| `build` | None | Push & PR |
|
||||
| `deploy` | SSH secrets + vars | Manual on `develop` |
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules/
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# BUILD: TypeScript + Vite
|
||||
# ──────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (tsc + Vite)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-alpine
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules/
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
env:
|
||||
VITE_API_ORIGIN: ${{ vars.VITE_API_ORIGIN || '' }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DEPLOY (manual): to shared hosting via SSH
|
||||
# ──────────────────────────────────────────────
|
||||
deploy:
|
||||
name: Deploy to shared hosting
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: alpine:3.20
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
needs: [lint, build]
|
||||
environment:
|
||||
name: production
|
||||
url: ${{ vars.PRODUCTION_URL }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH and rsync
|
||||
run: |
|
||||
apk add --no-cache openssh-client sshpass rsync
|
||||
|
||||
- name: Download built artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- name: Deploy built files to shared hosting
|
||||
run: |
|
||||
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
|
||||
-e "ssh -p ${{ vars.SSH_PORT || 22 }} -o StrictHostKeyChecking=no" \
|
||||
--exclude='.htaccess' \
|
||||
./dist/ \
|
||||
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ vars.SSH_TARGET_DIR }}/
|
||||
@@ -0,0 +1,20 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor/OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -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
|
||||
@@ -0,0 +1,32 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# If npm/yarn fail with "unable to get local issuer certificate" during install, build with:
|
||||
# docker compose build --build-arg NPM_STRICT_SSL=false client-prod
|
||||
# (Only use on trusted networks; prefer mounting your org CA via NODE_EXTRA_CA_CERTS instead.)
|
||||
ARG NPM_STRICT_SSL=true
|
||||
RUN if [ "$NPM_STRICT_SSL" = "false" ]; then npm config set strict-ssl false; fi
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
ENV CI=true \
|
||||
npm_config_maxsockets=1 \
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
|
||||
# Mitigate npm "Exit handler never called!" in some Docker/npm builds (see npm/cli issues).
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_ORIGIN=
|
||||
ENV VITE_API_ORIGIN=$VITE_API_ORIGIN
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NPM_STRICT_SSL=true
|
||||
RUN if [ "$NPM_STRICT_SSL" = "false" ]; then npm config set strict-ssl false; fi
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
ENV CI=true \
|
||||
npm_config_maxsockets=1 \
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
COPY docker-entrypoint.dev.sh /docker-entrypoint.dev.sh
|
||||
RUN chmod +x /docker-entrypoint.dev.sh
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.dev.sh"]
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
client:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
|
||||
ports:
|
||||
- '5173:5173'
|
||||
volumes:
|
||||
- .:/app
|
||||
- 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:8000}
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
client-prod:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_ORIGIN: ${VITE_API_ORIGIN:-}
|
||||
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
|
||||
ports:
|
||||
- '8080:80'
|
||||
profiles:
|
||||
- prod
|
||||
|
||||
volumes:
|
||||
client_node_modules:
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
cd /app
|
||||
|
||||
if [ ! -f node_modules/.installed ]; then
|
||||
export CI=true
|
||||
export npm_config_maxsockets=1
|
||||
export NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
npm install --no-audit --no-fund
|
||||
touch node_modules/.installed
|
||||
fi
|
||||
|
||||
exec npm run dev -- --host 0.0.0.0 --port 5173
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Cache-Control" content="no-store" />
|
||||
<title>Al Rahma Sunday School</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600&family=Inter:wght@600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
defer
|
||||
></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "client_laravel",
|
||||
"private": true,
|
||||
"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",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"swagger-ui-react": "^5.32.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"title": "School Policies",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "",
|
||||
"subsections": {
|
||||
"title": "",
|
||||
"body": "Dear Parents and Students,\n\nIt is our pleasure to greet and welcome you to our school year 2025-2026. Classes are scheduled to start at ISGL (Islamic Society of Greater Lowell) Masjid starting 09-21-2025. We start each year with the vision of achieving excellence in every aspect at Al Rahma Sunday School at ISGL.\n\nAs with any school, there are policies, procedures and guidelines that must be followed to ensure a smooth school operation.\n\nWe therefore expect your support and cooperation to achieve the goals and objectives of Al Rahma Sunday School and to provide a solid foundation for the future of our children."
|
||||
}
|
||||
},
|
||||
{
|
||||
"heading": "POLICIES AND PROCEDURES",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "Objective",
|
||||
"body": "Al Rahma main goal is to make the Masjid a central figure in the lives of our Muslim children from a very young age. Al Rahma is also committed to offering its students a solid curriculum of Quran, Arabic and Islamic Studies. Al Rahma will strive to provide the highest education possible to instill Islamic practices and values into each student."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "Registration",
|
||||
"body": "The school offers a solid curriculum of Islamic Studies and Quran\/Arabic through 9 progressive grades, after which the students will join a 3-year rotation youth program <u>with a minimum age rule of at least 15<\/u>. <strong><u>Starting this year, to be eligible to get into 1st grade, your child needs to be at least 6 years old by 12-31-2025. Younger students will have the possibility to be enrolled in our newly created Kindergarten class if they are at least 5 years old by 12-31-2025.<\/u><\/strong>\n\nRegistration this year will open <strong><u>from 09-01-2025 to 10-01-2025<\/u><\/strong>. We highly encourage parents to enroll their children early so they will not miss out on early classes during the year. <strong><u>By 10-01-2025 at midnight<\/u><\/strong>, registration will have closed and parents will not be allowed to register their kids except if the said parents just recently moved to the area from a faraway town\/city\/state.\n\nThe school will make every effort to broadcast the opening of the registration campaign and communicate the registration link through all means available at the time (school email list, ISGL Whatsapp group, website, social media, …). <strong><u>Starting this year, all parents have to create accounts in the official school website <a href=\"https:\/\/alrahmaisgl.org\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color: #007BFF; text-decoration: underline;\">https:\/\/alrahmaisgl.org<\/a> to be able to register their kids and enroll them in classes. All registrations without exception will happen online, we highly encourage parents to pay tuition online as well<\/u><\/strong>. During the first 2-3 weeks of school, there will be dedicated school staff at the front desk who will help with in-person payments, just walk in and ask for an admin to help you.\n\nPlease make sure your contact information is correct and up to date especially the phone numbers and emails section because that would be our only way to get in contact with you regarding school matters. Also, please make sure to fill out the known allergies section accurately so that we ensure your kids are never presented with food they could be allergic to.\n\n<strong><u>We only accept students with ages between 5 and 18<\/u><\/strong>. For younger students, they must be <strong><u>at least 6 years old by 12-31-2025<\/u><\/strong> to be admitted, there will be no exceptions. <strong><u>Students that are 5 years old by 12-31-2025 can enroll in our newly created Kindergarten class<\/u><\/strong>. Students outside of the range described above are considered too young or too old for the activities offered by the school and will not be eligible to enroll with us. Please do not register your kids if they do not meet the criteria described above.\n\nAfter the registration period ends, parents will be invited to join WhatsApp grade groups where their kids are enrolled. These groups allow teachers, parents and administration to stay close and communicate efficiently about all school matters and events."
|
||||
},
|
||||
{
|
||||
"title": "Tuition",
|
||||
"body": "All parents will be asked to <u>pay tuition for the year<\/u> or come to an agreement with the administration about an interest-free installment plan where <u>payments are made at the beginning of each month starting October 1st<\/u>. Accepted methods of payments are cash, check and debit\/credit card. Tuition covers school expenses related to operations, purchase of books\/supplies\/materials, organizing events for students, etc... The school is committed to offer high quality services while maintaining low cost of operations so that tuition is affordable by the majority of parents. <u>Annual<\/u> tuition for this school year is as follows:\n\n<u>Kindergarten & Grades 1 – 9<\/u>: <strong>$370<\/strong> per child + <strong>$220<\/strong> per additional child\n<u>Youth<\/u>: $200\n\nExamples:\n <table border=\"1\" cellspacing=\"0\" cellpadding=\"8\" style=\"border-collapse: collapse; text-align: center; width: 100%;\">\n <thead>\n <tr>\n <th><\/th>\n <th colspan=\"3\">Example<\/th>\n <\/tr>\n <tr>\n <th><\/th>\n <th>1<\/th>\n <th>2<\/th>\n <th>3<\/th>\n <\/tr>\n <\/thead>\n <tbody>\n <tr>\n <td># kids in K & Grades 1–9<\/td>\n <td>1<\/td>\n <td>2<\/td>\n <td>3<\/td>\n <\/tr>\n <tr>\n <td># kids in Youth<\/td>\n <td>0<\/td>\n <td>0<\/td>\n <td>1<\/td>\n <\/tr>\n <tr>\n <td>Total Tuition<\/td>\n <td><strong>$370<\/strong><\/td>\n <td><strong>$370 + $220 = $590<\/strong><\/td>\n <td><strong>$370 + 2×$220 + $200 = $1010<\/strong><\/td>\n <\/tr>\n <\/tbody>\n <\/table>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "SCHOOL HOURS",
|
||||
"body": "On regular days, Al Rahma School starts at 10:00 AM and finishes at 1:00 PM every Sunday (except Sundays off noted in the calendar). There are two main sessions; the first session from 10:00 AM to 11:30 AM is dedicated to Islamic Studies while the second session from 12:00 PM to 1:00 PM is dedicated to Quran\/Arabic. Students get 30 min recess to socialize, play and eat snacks. It is highly recommended that parents provide their children with snacks. In case some students forget theirs, the school may occasionally provide snacks for them when available.\n\nThe ISGL lower-level space is entirely reserved for school operations on Sundays between 8:00 AM to 1:00 PM. <strong><u>For safety and logistical reasons, no one is allowed to be in school premises between 10:00 AM to 1:00 PM except for students and staff<\/u><\/strong>. Parents are allowed to come in to drop off their kids and leave right after. Parents who wish to speak with a school admin or teacher can do so before school starts (before 10:00 AM) or after school is finished (after 1:00 PM).\n\nIn Ramadan, Al Rahma School starts at 11:00 AM and finishes at 1:00 PM. First session will be an hour long instead of 90 minutes while the second session will last 45 minutes with a 15 minutes break in between. In observance of the last 10 nights of Ramadan leading to Eid Al-Fitr, there will be no school on the 15th and 22nd of March (see calendar)."
|
||||
},
|
||||
{
|
||||
"title": "ATTENDANCE",
|
||||
"body": "Students are expected to attend all classes each Sunday. Each absence interferes with your child’s ability to benefit from his\/her class. In case of absence, parents are asked to inform the school administration <strong><u>ahead of Sunday session<\/u><\/strong> through call, text (regular & WhatsApp) or email to explain the reason for the absence. Failure to comply has the following progressive consequences:\n\n a- If unnotified, teachers will ask students for the reason for their absence when they\n meet up the following Sunday. Any assignments missed should be made up by students \n within the time frame given by the teacher.\n\n b- <strong>Two consecutive absences<\/strong> or <strong>two absences in any three-week streak<\/strong> <u>without<\/u> \n <u>prior notice<\/u> will trigger a phone call and email from the administration to follow up \n with parents. \n\n c- <strong>Three consecutive absences<\/strong> or <strong>three absences in any four-week streak<\/strong> <u>without<\/u> \n <u>prior notice<\/u> will trigger a final warning call, text and email. \n\n d- <strong>Four consecutive absences<\/strong> or <strong>four absences in any five-week streak<\/strong> <u>without<\/u> \n <u>prior notice<\/u> will result in student dismissal from the school.\n\nThis is how you <strong>justify an absence\/late arrival<\/strong>:\n\n\n<u>When?<\/u> Contact us before classes start on Sunday, preferably at least 24 hours before\n\n<u>How?<\/u> Notify us by one of these 4 methods:\n<ul><li>Send an email to <a href=\"mailto:alrahma.isgl@gmail.com\" style=\"color: #007BFF; text-decoration: underline;\">alrahma.isgl@gmail.com<\/a><\/li><li>Call +1 978-364-0219. Leave a voicemail if no one picks up.<\/li><li>Text +1 978-364-0219<\/li><li>Leave a WhatsApp message in your student grade group<\/li><\/ul>\n<u>What?<\/u> We need to know:\n<ul><li>Student full name (first name + last name)<\/li><li>Student grade<\/li><li>Date and duration of absence\/late<\/li><li>Reason for absence\/late<\/li><\/ul>\nLike the previous year, <strong><u>absence (reported or unreported) will account for 20% of overall student grade except for long term leave with prior notification to administration<\/u><\/strong>. One sick day per semester is typically granted to all students and will not be counted toward the final grade. This policy has been put in place to keep all parties honest. Being absent means missing out on important learning opportunities, that’s why <strong><u>ALL<\/u><\/strong> absences will be marked and used toward the 20% target of overall grade.\n\nNot showing up for events organized by the school also counts as absence like any other regular session. Missing out on exams, school projects\/assignments <u>purposefully and without a valid reason<\/u> will result in a grade of 0 for that particular item. No make up exam will be scheduled in that case. \n\n<u>School hours are 10:00 AM to 1:00 PM<\/u>. Half-day attendance is not allowed: a child will not be allowed to only attend one of the sessions and be excused from the other without a valid excuse."
|
||||
},
|
||||
{
|
||||
"title": "Illness",
|
||||
"body": "Out of consideration for other children, parents are requested to keep their sick child at home until he\/she feels better. Children will need one fever free day before coming to school. Due to the flu season every fall\/winter, we urge all parents to keep their children at home, if they have flu-like symptoms."
|
||||
},
|
||||
{
|
||||
"title": "Tardy Policy",
|
||||
"body": "Excessive tardiness interferes with the student’s ability to benefit from class. Also, it disrupts the flow of the lesson and causes distraction for students and teachers. Students are expected to arrive on time and be prepared to start class at 10:00 AM each Sunday. \nIn continuation of our 0-tolerance policy toward tardiness, the following measures will be adopted:\n<ul><li>If a student arrives after 10:00 AM, he\/she will not be allowed in class unless he\/she gets a tardy slip from the administration which he\/she will hand to the teacher before entering the classroom.<\/li><li><strong><u>School doors will be locked after 10:10 AM<\/u><\/strong>, parents dropping their kids after that time will be asked to take them back home <u>unless we receive a written message (email, text, WhatsApp) before 10:10 AM letting us know about the late arrival<\/u>. If the student is asked to leave, this will count as one absence on the student record.<\/li><li>A student will typically be forgiven for four late showings (arriving between 10:00 AM and 10:10 AM) per school year. Any subsequent late showings (five and up) will result in the student being sent home and marked as absent.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "Drop-Off",
|
||||
"body": "Parents must bring their children on time so that they are not penalized for late showings, they should typically plan to arrive 5 to 10 minutes before classes start. For extra security, there will be assigned school staff members every Sunday morning from 9:45 AM to 10:00 AM to enforce parking safety protocols. Parents are also kindly reminded to be mindful of other drivers behind them who are waiting to drop off their children. Upon arrival, get in the line queue and stay in your car until you reach the front of the Masjid. When it is your turn, and only when instructed by one of our volunteers, you can then drop off your kids, then please leave immediately when it is safe. No need to park or get out of your car. Some cars have safety features requiring someone from outside to open the door, our volunteers will do that for you. If you insist on coming with your children inside, then the back of the Masjid will be reserved for that reason. If you choose this option, then you need to park in the back and escort your children inside. \n\nOur volunteers will show you where you can safely park without hindering the flow of other cars. No one is allowed to park in any parking spot facing the Masjid, all of those are reserved for staff members only. Everyone <strong><u>MUST<\/u><\/strong> follow instructions of our volunteers in the parking lot. It is very dangerous when parents get impatient and try to go from the sides or drop off their kids when it is not their turn yet.\nFailure to follow the aforementioned guidelines purposefully, or arguing\/getting aggressive with one of the volunteers will be dealt with swiftly. Zero tolerance for staff members getting disrespected or yelled at!\n<br>\n<div style=\"text-align: center;\">\n<br>\n<img src=\"\/assets\/images\/parking.png\" alt=\"Parking Map\" style=\"width:90%; height:auto; border:1px solid #ccc; padding:4px;\"><\/div>"
|
||||
},
|
||||
{
|
||||
"title": "PICK-UP & EARLY RELEASE",
|
||||
"body": "School ends at 1:00 PM. For safety reasons, students are allowed to leave the school <strong><u>only<\/u><\/strong> if a parent comes inside and picks them up for all grades, no exceptions. Youth who have their own car or elect to walk home will need to tell one of the staff before leaving. The school encourages parents and students to stay until 1:15 PM to pray Duhr in congregation in the Masjid which usually takes about 5 to 10 minutes.\n\nIn general, early pick-up is a practice the school discourages, but if a child needs to leave early, the parent is required to email the school in advance and state the student’s full name, the reason for early release and the pick-up date\/time. The parent picking up the child needs also to sign the “Early Release Form” found at the front desk."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "EXAMS, GRADING & PASS\/FAIL",
|
||||
"body": "In coordination with all teachers, the school organizes two official exams throughout the year that count toward pass\/fail criteria for the following year: \n\n a) <strong>Midterm\/Winter Exam<\/strong> after about 13 weeks of active sessions. This marks the end of \n the first semester<br>\n b) <strong>Final\/Spring Exam<\/strong> after about 13 weeks of active sessions that follow the midterm. \n This marks the end of the second semester\n\nFor this current school year, please refer to the calendar on the first page for exact exam dates. A report card will be shared with you after each semester.\n\nMidterm and final exams are each scored out of a 100. They have an “Islamic Studies” and “Quran” portion, teachers decide the weight of each portion. On the Islamic Studies portion, questions will be diversified in style, innovative and thought-provoking that challenge students critical thinking skills. On the Quran portion, students will be tested on memorization and meaning\/understanding of the verses.\n\nStudents must have a valid reason for not showing up to these two major exams like sickness, travel or emergency. School will work with said students on a make-up exam the following school day. Make-up exams will not be provided for students who are absent for no valid reason, a reason will be deemed valid or invalid at the discretion of the administration.\n\nThere will be projects and assignments given to students throughout the year. It is very important for parents to follow up with their kids to make sure that projects and assignments are completed promptly. There can be times where teachers decide to have a test\/quiz in class generally limited in scope to keep students engaged with class material throughout the year. Projects, Tests, Assignments and Participation (referred in this document as PTAP) accounts for 20% of final grade.\n\nAttendance for this year will account for 20% of overall grade, <strong><u>ALL<\/u><\/strong> absences will be counted except for one sick day per semester.\n\nFinal Grade Distribution:\n\n a) Attendance: 20%\n b) Projects, Tests, Assignments and Participation (PTAP): 20%\n c) Midterm: 30%\n d) Final: 30%\n<br>\n<div style=\"text-align: center;\">\n<br>\n <img src=\"\/assets\/images\/score.png\" alt=\"score graph\" style=\"width:75%; height:auto; border:1px solid #ccc; padding:4px;\">\n<\/div>\n\nFinal grade is the main criterion to determine students passing to next grades for the following school year. The objective criteria that the school uses is as follows:\n\n<strong>Final Grade >= 60<\/strong>: Automatic Pass\n<strong>50 <= Final Grade < 60<\/strong>: Up for Debate\n<strong>Final Grade < 50<\/strong>: Fail\n\nAt the request of parents, last resort make-up exams can be made available for failing students at the beginning of the following school year to assess if they have made an effort during summer to study and rectify the poor showing during the previous year. If the result is satisfactory (at the discretion of the teacher and administration), then the said students will be allowed to progress to the next grade, otherwise they will have to repeat the class. Passing or failing a grade is a matter discussed internally by the principal and the vice-principal after the final exam week. Final decisions are made at the discretion of the school. All decisions are final."
|
||||
},
|
||||
{
|
||||
"title": "Events",
|
||||
"body": "The school will typically try to organize two fun events for students throughout the year to make the Islamic learning experience much more appealing. In previous years for example, the school organized a World Cup Final event indoors where all students got together and watched the game, food and snacks were provided. Also, an Islamic quiz competition was organized to assess students’ levels and top contenders received cash prizes. School also organized outdoor Laser Tag events and Trampoline Park outing which were well received by parents and were entertaining for students and staff. Event related information will always be communicated to parents ahead of time for better planning."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "Parent-School Communication",
|
||||
"body": "Please feel free to communicate any suggestions\/concerns you may have about the school, your child, teacher, or staff. We welcome your feedback to let us know how we are doing. See “Contact Information” section below for ways to contact us.\n\nFor phone calls, we will pick up the phone on Sundays from 9:30 AM to 1:00 PM. Also, you can contact the school anytime and leave a voicemail\/text message. A staff member from the communications department will respond to you within 48 hours."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "TEACHERS\/STAFF RESPONSIBILITIES",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "",
|
||||
"body": "All teachers and staff members are entrusted with the sacred duty of nurturing, educating, and safeguarding the well-being of every student. These responsibilities stem from our commitment to Islamic values, academic excellence and the trust placed in us by families. The following outlines the expectations and responsibilities of all school personnel toward the students in our care:"
|
||||
},
|
||||
{
|
||||
"title": "1. Uphold Islamic Character and Conduct",
|
||||
"body": "<ul><li>Serve as role models by displaying Islamic manners, modesty, humility, patience, and compassion in speech and action.<\/li><li>Avoid harsh speech, sarcasm, ridicule, or any behavior that may cause embarrassment or emotional harm to students.<\/li><li>Foster an environment of respect, empathy, and kindness among all students.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "2. Ensure Physical and Emotional Safety",
|
||||
"body": "<ul><li>Maintain supervision of students at all times during class hours, breaks, and transitions.<\/li><li>Report any concerns of bullying, neglect, or abuse (verbal, emotional, or physical) to administration immediately.<\/li><li>Never use any form of physical discipline. Discipline must be positive, constructive, and in line with school policy.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "3. Promote a Positive Learning Environment",
|
||||
"body": "<ul><li>Arrive on time and prepared for each class, with a clear lesson plan and necessary materials.<\/li><li>Engage students with age-appropriate, interactive, and relevant Islamic teachings.<\/li><li>Encourage participation and respect diverse levels of understanding and backgrounds.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "4. Maintain Professional Boundaries",
|
||||
"body": "<ul><li>Refrain from forming inappropriate or exclusive personal relationships with students.<\/li><li>Avoid one-on-one communication with students outside of class without parental or administrative awareness (e.g., texting, social media).<\/li><li>Use school communication platforms and maintain transparency in all interactions.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "5. Communicate with Families Respectfully",
|
||||
"body": "<ul><li>Collaborate with parents\/guardians for the benefit of the child’s learning and character development.<\/li><li>Inform parents promptly and respectfully about any significant concerns, absences, or behavioral issues.<\/li><li>Maintain confidentiality and professionalism when discussing student matters.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "6. Report and Record Keeping",
|
||||
"body": "<ul><li>Take accurate attendance and report it as per school policy.<\/li><li>Document any serious incidents, concerns, or interventions and share them with the administration.<\/li><li>Assist in maintaining class records, student progress, and feedback when applicable.<\/li><\/ul>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "STUDENT CODE OF CONDUCT",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"title": "1. Respect and Dignity",
|
||||
"body": "<ul><li>Respect for Others: All students are expected to treat their peers, teachers, and staff with respect, kindness, and consideration. This includes refraining from any form of bullying, harassment, or discrimination.<\/li><li>Respect for Property: Students must respect school property, personal belongings of others, and the environment.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "2. Adherence to Islamic Values",
|
||||
"body": "<ul><li>Modesty in Dress: Students must adhere to the school’s dress code, which reflects Islamic principles of modesty. Short pants are not allowed. All girls should wear head scarves\/Hijab.<\/li><li>Honesty and Integrity: Cheating, lying, or stealing is strictly prohibited.<\/li><li>Behavior and Language: Students are expected to use appropriate language and exhibit behavior that reflects Islamic teachings, avoiding foul language, gossip, and backbiting.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "3. Attendance and Punctuality",
|
||||
"body": "<ul><li>Regular Attendance: Students must attend school regularly and arrive on time.<\/li><li>Punctuality in Submissions: Homework and assignments must be completed and submitted on time.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "4. Engagement and Effort",
|
||||
"body": "<ul><li>Participation: Students are encouraged to actively participate in classes and school activities.<\/li><li>Effort in Academics: All students should strive to achieve their best in all academic and extracurricular endeavors.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "5. Safety and Well-being",
|
||||
"body": "<ul><li>Safe Environment: Students must refrain from actions that endanger themselves or others.<\/li><li>Prohibition of Harmful Substances: The possession or use of harmful substances, such as tobacco, drugs, or alcohol, is strictly forbidden.<\/li><\/ul>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "PROGRESSIVE DISCIPLINE ACTIONS",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"title": "1. Minor Infractions",
|
||||
"body": "Examples: Tardiness, incomplete homework, minor disruptions in class.\nActions:\n<ul><li>Verbal Warning: Teacher addresses the issue directly with the student.<\/li><li>Written Warning: A note is sent home, and parents are informed.<\/li><li>Loss of Privileges: Temporary loss of privileges, such as recess or extracurricular activities.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "2. Moderate Infractions",
|
||||
"body": "Examples: Repeated minor infractions, disrespectful behavior, dress code violations, unauthorized use of telephones or other electronic devices during class.\nActions:\n<ul><li>Confiscation: The teacher may confiscate the phone or device for the remainder of the day.<\/li><li>Parent-Teacher Conference: A meeting is arranged to discuss the student’s behavior and possible solutions.<\/li><li>Behavioral Contract: The student may be placed on a behavioral contract outlining expected behavior and consequences for further infractions.<\/li><li>Detention: The student may be required to stay after school or during recess to complete missed work or reflect on their behavior.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "3. Serious Infractions",
|
||||
"body": "Examples: Bullying, cheating, theft, physical aggression, or any behavior that compromises the safety of others.\nActions:\n<ul><li>In-School Suspension: The student is removed from regular classes but remains in school.<\/li><li>Out-of-School Suspension: Temporary suspension from school, requiring a meeting with parents before the student is allowed to return.<\/li><li>Counseling: Mandatory counseling sessions to address underlying issues.<\/li><\/ul>"
|
||||
},
|
||||
{
|
||||
"title": "4. Extreme Infractions",
|
||||
"body": "Examples: Possession of weapons, illegal substances, severe physical violence.\nActions:\n<ul><li>Long-Term Suspension: Extended suspension, possibly leading to expulsion depending on the severity of the infraction.<\/li><li>Expulsion: Permanent removal from the school. This is considered a last resort and is only implemented after a thorough review by the school administration.<\/li><\/ul>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "STUDENTS & PARENTS RESPONSIBILITIES",
|
||||
"subsections": {
|
||||
"body": "Students:\n<ul><li>Attend class regularly.<\/li><li>Have appropriate materials for class.<\/li><li>Arrive at school prepared to learn.<\/li><li>Recognize and respect the rights of classmates.<\/li><li>Respect teachers at all times.<\/li><li>Respect the Masjid and its facilities.<\/li><li>Comply with and conform to all class\/school rules.<\/li><\/ul>Parents:\n<ul><li>Be aware of and understand school policies.<\/li><li>Help promote a positive attitude.<\/li><li>Help students in regard to attendance and studying.<\/li><li>Communicate with teachers.<\/li><li>Communicate any concerns to the administration.<\/li><li>Report any changes in email and phone to school staff.<\/li><\/ul>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"heading": "PARENTAL CONSENT",
|
||||
"subsections": [
|
||||
{
|
||||
"title": "1. Consent for School Activities",
|
||||
"body": "\nI hereby grant permission for my child(ren) to participate in activities organized by Al Rahma Sunday School during the current school year, in addition to the regular scheduled classes. I understand that these activities may include, but are not limited to, field trips, sports events and extracurricular programs."
|
||||
},
|
||||
{
|
||||
"title": "2. Liability Release",
|
||||
"body": "\nI acknowledge that participation in school activities involves certain inherent risks. I hereby release and hold harmless Al Rahma Sunday School, its staff, volunteers, agents and representatives from any liability for injury, loss or damage that may arise from my child(ren)’s participation in these activities."
|
||||
},
|
||||
{
|
||||
"title": "3. Medical Authorization",
|
||||
"body": "\nIn the event of a medical emergency or illness during a school activity, I authorize Al Rahma Sunday School to seek medical care, treatment or transportation to a medical facility for my child(ren), as deemed necessary by school personnel. I understand that every effort will be made to contact me or the designated emergency contact first. If I or the emergency contact cannot be reached, I authorize the school to act in the best interest of my child(ren)’s health and safety."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "CONTACT INFORMATION",
|
||||
"subsections": {
|
||||
"body": "<p>Email: <a href=\"mailto:alrahma.isgl@gmail.com\" style=\"color: #007BFF; text-decoration: underline;\">alrahma.isgl@gmail.com<\/a><br>Call\/Text: +1 978-364-0219<\/p>\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the Vite dev container from WSL (Docker Desktop with WSL2 backend, or Docker Engine in WSL).
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# chmod +x scripts/docker-up-wsl.sh
|
||||
# ./scripts/docker-up-wsl.sh
|
||||
#
|
||||
# Or from anywhere:
|
||||
# bash /mnt/d/Test/alrahma/client_laravel/scripts/docker-up-wsl.sh
|
||||
#
|
||||
# Windows D: drive in WSL is usually: /mnt/d/Test/alrahma/client_laravel
|
||||
#
|
||||
# Laravel API proxy: defaults to host port 8080. Override if requests fail:
|
||||
# export VITE_PROXY_API=http://172.17.0.1:8080
|
||||
# ./scripts/docker-up-wsl.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ "$(pwd)" == /mnt/* ]]; then
|
||||
echo "Note: Project is on a Windows mount (/mnt/...). File sync can be slower; Vite uses polling." >&2
|
||||
fi
|
||||
|
||||
export VITE_PROXY_API="${VITE_PROXY_API:-http://host.docker.internal:8080}"
|
||||
|
||||
echo "Using VITE_PROXY_API=${VITE_PROXY_API}" >&2
|
||||
echo "App → http://localhost:5173 (ensure Laravel is reachable from the container at the proxy target)" >&2
|
||||
|
||||
exec docker compose up --build client
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$policyHtmlPath = '/Volumes/ExternalApps/Documents/alrahma_sunday_school_api/resources/policies/school_policy.html';
|
||||
|
||||
if (!is_file($policyHtmlPath)) {
|
||||
fwrite(STDERR, "Policy source not found: {$policyHtmlPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$html = file_get_contents($policyHtmlPath);
|
||||
if ($html === false) {
|
||||
fwrite(STDERR, "Failed to read policy source\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'title' => 'School Policy',
|
||||
'sections' => [
|
||||
[
|
||||
'subsections' => [
|
||||
[
|
||||
'body' => $html,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$outDir = $root . '/public/policy';
|
||||
if (!is_dir($outDir)) {
|
||||
mkdir($outDir, 0777, true);
|
||||
}
|
||||
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
if ($json === false) {
|
||||
fwrite(STDERR, "json_encode failed\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
file_put_contents($outDir . '/school-policy-content.json', $json);
|
||||
echo "Wrote public/policy/school-policy-content.json\n";
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Generates src/lib/viewRouteMeta.generated.ts from discovered PHP view files.
|
||||
* Run: node scripts/gen-view-route-meta.mjs
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const parentRoot = path.resolve(projectRoot, '..')
|
||||
const outFile = path.resolve(projectRoot, 'src/lib/viewRouteMeta.generated.ts')
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'layout',
|
||||
'partials',
|
||||
'errors',
|
||||
'pagination',
|
||||
'docs',
|
||||
])
|
||||
|
||||
function humanize(slug) {
|
||||
const normalized = slug.replace(/(\.blade)?\.php$/i, '').replace(/_/g, ' ')
|
||||
return normalized.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function walk(dir, baseRel) {
|
||||
const out = []
|
||||
let entries = []
|
||||
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const rel = path.join(baseRel, entry.name)
|
||||
const full = path.join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
out.push(...walk(full, rel))
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isFile() && /\.php$/i.test(entry.name)) {
|
||||
out.push(rel.replace(/\\/g, '/'))
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function discoverViewRoots() {
|
||||
const roots = []
|
||||
const fromEnv = process.env.VIEW_SOURCE_ROOT?.trim()
|
||||
|
||||
if (fromEnv) {
|
||||
roots.push(path.resolve(projectRoot, fromEnv))
|
||||
}
|
||||
|
||||
let siblings = []
|
||||
try {
|
||||
siblings = fs.readdirSync(parentRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(parentRoot, entry.name))
|
||||
} catch {
|
||||
siblings = []
|
||||
}
|
||||
|
||||
for (const sibling of siblings) {
|
||||
roots.push(path.join(sibling, 'app', 'Views'))
|
||||
roots.push(path.join(sibling, 'resources', 'views'))
|
||||
}
|
||||
|
||||
return [...new Set(roots)].filter((root) => fs.existsSync(root))
|
||||
}
|
||||
|
||||
function resolveViewsRoot() {
|
||||
let best = null
|
||||
let bestCount = -1
|
||||
|
||||
for (const candidate of discoverViewRoots()) {
|
||||
const count = walk(candidate, '').length
|
||||
if (count > bestCount) {
|
||||
best = candidate
|
||||
bestCount = count
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
function main() {
|
||||
const viewsRoot = resolveViewsRoot()
|
||||
if (!viewsRoot) {
|
||||
console.error('Missing PHP view source folder. Set VIEW_SOURCE_ROOT to override discovery.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const files = walk(viewsRoot, '')
|
||||
.filter((file) => !file.includes('/layout/'))
|
||||
.sort()
|
||||
|
||||
const entries = []
|
||||
for (const file of files) {
|
||||
const parts = file.split('/')
|
||||
const base = parts.pop().replace(/(\.blade)?\.php$/i, '')
|
||||
const routeKey = [...parts, base].filter(Boolean).join('/')
|
||||
const sourceViewPath = file
|
||||
const title = humanize(base)
|
||||
entries.push({ routeKey, title, sourceViewPath })
|
||||
}
|
||||
|
||||
const lines = []
|
||||
lines.push('/** Auto-generated by scripts/gen-view-route-meta.mjs — do not edit by hand. */')
|
||||
lines.push('')
|
||||
lines.push('export type ViewRouteMetaEntry = {')
|
||||
lines.push(' title: string')
|
||||
lines.push(' sourceViewPath: string')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push('export const viewRouteMeta: Record<string, ViewRouteMetaEntry> = {')
|
||||
|
||||
for (const { routeKey, title, sourceViewPath } of entries) {
|
||||
const safeTitle = title.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
||||
const safeView = sourceViewPath.replace(/\\/g, '/')
|
||||
lines.push(` '${routeKey}': { title: '${safeTitle}', sourceViewPath: '${safeView}' },`)
|
||||
}
|
||||
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
fs.mkdirSync(path.dirname(outFile), { recursive: true })
|
||||
fs.writeFileSync(outFile, lines.join('\n'), 'utf8')
|
||||
console.log(`Wrote ${entries.length} entries → ${path.relative(process.cwd(), outFile)}`)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Smoke-test the same API routes the Vite client uses.
|
||||
* Usage: node scripts/test-api.mjs
|
||||
* $env:API_BASE="http://127.0.0.1:5173"; node scripts/test-api.mjs # via Vite proxy
|
||||
*/
|
||||
const base = (process.env.API_BASE ?? 'http://127.0.0.1:8080').replace(/\/$/, '')
|
||||
|
||||
const tests = [
|
||||
['GET', '/up', null],
|
||||
['GET', '/api/v1/health', null],
|
||||
['GET', '/api/v1/auth/register/captcha', null],
|
||||
['GET', '/api/v1/policies/school', null],
|
||||
['GET', '/api/docs/public', null],
|
||||
[
|
||||
'POST',
|
||||
'/api/v1/auth/login',
|
||||
JSON.stringify({ email: 'nosuch@example.org', password: 'wrong-password' }),
|
||||
],
|
||||
]
|
||||
|
||||
async function main() {
|
||||
console.log(`API_BASE=${base}\n`)
|
||||
|
||||
for (const [method, path, body] of tests) {
|
||||
const url = `${base}${path}`
|
||||
try {
|
||||
const init = {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body ? { body } : {}),
|
||||
}
|
||||
const res = await fetch(url, init)
|
||||
const text = await res.text()
|
||||
let snippet = text.slice(0, 280).replace(/\s+/g, ' ')
|
||||
let fatal = false
|
||||
let dbError = false
|
||||
try {
|
||||
const j = JSON.parse(text)
|
||||
const msg =
|
||||
typeof j.message === 'string'
|
||||
? j.message
|
||||
: typeof j.exception === 'string'
|
||||
? j.exception
|
||||
: ''
|
||||
if (j.exception?.includes?.('FatalError') || /FatalError/i.test(j.message ?? '')) {
|
||||
fatal = true
|
||||
snippet = `PHP FatalError: ${(j.message ?? '').slice(0, 100)}`
|
||||
} else if (j.message && typeof j.message === 'string') {
|
||||
snippet = j.message.slice(0, 120)
|
||||
}
|
||||
if (/SQLSTATE/i.test(msg) || /SQLSTATE/i.test(text)) {
|
||||
dbError = true
|
||||
snippet = `${snippet.slice(0, 80)}… (DB unavailable?)`
|
||||
}
|
||||
if (j.ok === true && path.includes('captcha')) {
|
||||
snippet = `ok=true captcha=<${String(j.captcha ?? '').length} chars>`
|
||||
}
|
||||
if (j.status === true && path.includes('docs/public')) {
|
||||
snippet = `title=${j.title ?? 'n/a'} openapi=${!!j.openapi_url}`
|
||||
}
|
||||
if (j.data?.policy && path.includes('policies/school')) {
|
||||
snippet = `policy content len=${String(j.data.policy.content ?? '').length}`
|
||||
}
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
const ok =
|
||||
!fatal &&
|
||||
!dbError &&
|
||||
(res.ok ||
|
||||
(path.endsWith('/login') && (res.status === 401 || res.status === 400)) ||
|
||||
res.status === 422)
|
||||
console.log(
|
||||
`${method} ${path} → ${res.status} ${ok ? '✓' : '✗'} ${snippet}`,
|
||||
)
|
||||
} catch (e) {
|
||||
console.log(`${method} ${path} → ERROR ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,75 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.docs-root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-banner {
|
||||
background-color: #15654f;
|
||||
color: white;
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.admin-banner span {
|
||||
color: #f2f2f2;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.docs-loading,
|
||||
.docs-error {
|
||||
padding: 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.docs-error {
|
||||
color: #b00020;
|
||||
}
|
||||
|
||||
.admin-docs-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.docs-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e8f0ec;
|
||||
border-bottom: 1px solid #c5d5ce;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.docs-toolbar input {
|
||||
min-width: 220px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.docs-toolbar a {
|
||||
color: #15654f;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { apiFetch } from './http'
|
||||
|
||||
export type BadgeScanLogRow = {
|
||||
id: number
|
||||
user_id: number | null
|
||||
card_id: string
|
||||
scan_time: string
|
||||
school_year: string | null
|
||||
semester: string | null
|
||||
user_firstname: string | null
|
||||
user_lastname: string | null
|
||||
student_firstname: string | null
|
||||
student_lastname: string | null
|
||||
}
|
||||
|
||||
export type BadgeScanLogsResponse = {
|
||||
logs: BadgeScanLogRow[]
|
||||
}
|
||||
|
||||
function unwrap(body: unknown): unknown {
|
||||
if (body === null || body === undefined || typeof body !== 'object') return body
|
||||
const o = body as Record<string, unknown>
|
||||
if ('data' in o && o.data !== null && typeof o.data === 'object') return o.data
|
||||
return body
|
||||
}
|
||||
|
||||
export async function fetchBadgeScanLogs(): Promise<BadgeScanLogsResponse> {
|
||||
const raw = await apiFetch<unknown>('/api/v1/badge_scan/logs')
|
||||
const data = unwrap(raw) as BadgeScanLogsResponse
|
||||
return { logs: Array.isArray(data?.logs) ? data.logs : [] }
|
||||
}
|
||||
|
||||
export function displayNameOf(row: BadgeScanLogRow): string {
|
||||
const userFull = [row.user_firstname, row.user_lastname].filter(Boolean).join(' ').trim()
|
||||
if (userFull) return userFull
|
||||
const stuFull = [row.student_firstname, row.student_lastname].filter(Boolean).join(' ').trim()
|
||||
if (stuFull) return stuFull
|
||||
return row.card_id
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||
import type { ApiEnvelope } from './types'
|
||||
|
||||
export type CertificateDecisionRow = {
|
||||
decision: string
|
||||
source: string
|
||||
notes: string
|
||||
year_score: number | null
|
||||
}
|
||||
|
||||
export type CertificateStudentRow = {
|
||||
student_id: number
|
||||
firstname: string
|
||||
lastname: 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 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
|
||||
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 type CertificateAuditRecord = {
|
||||
certificate_number: string
|
||||
student_name: string
|
||||
grade?: string | null
|
||||
cert_date?: string | null
|
||||
school_year?: string | null
|
||||
admin_firstname?: string | null
|
||||
admin_lastname?: string | null
|
||||
issued_at?: string | null
|
||||
}
|
||||
|
||||
export type CertificateAuditPayload = {
|
||||
school_year: string
|
||||
records: CertificateAuditRecord[]
|
||||
year_summary: Array<{ school_year: string; total: number }>
|
||||
}
|
||||
|
||||
function authHeaders(accept: string) {
|
||||
const headers = new Headers({ Accept: accept })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
return headers
|
||||
}
|
||||
|
||||
function withQuery(path: string, params?: Record<string, string | number | null | undefined>) {
|
||||
const qs = new URLSearchParams()
|
||||
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 | null
|
||||
school_year?: string | null
|
||||
}): Promise<Blob> {
|
||||
const res = await fetch(apiUrl('/api/v1/administrator/certificates/generate'), {
|
||||
method: 'POST',
|
||||
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 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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Incidents / flags API (parity with CI `flags/*` + JSON routes in Laravel).
|
||||
* Expected base: `/api/v1/administrator/flags/...` with JWT.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
export type IncidentFlagRow = {
|
||||
id?: number
|
||||
flag_state?: string
|
||||
student_name?: string
|
||||
grade?: string
|
||||
flag?: string
|
||||
open_description?: string
|
||||
close_description?: string
|
||||
cancel_description?: string
|
||||
flag_datetime?: string | null
|
||||
action_taken?: string | null
|
||||
updated_by_open_name?: string | null
|
||||
updated_by_open?: string | number | null
|
||||
updated_by_closed_name?: string | null
|
||||
updated_by_closed?: string | number | null
|
||||
updated_by_canceled_name?: string | null
|
||||
updated_by_canceled?: string | number | null
|
||||
}
|
||||
|
||||
export type IncidentLogRow = {
|
||||
flag?: string
|
||||
flag_state?: string
|
||||
flag_datetime?: string | null
|
||||
open_description?: string
|
||||
close_description?: string
|
||||
cancel_description?: string
|
||||
action_taken?: string | null
|
||||
}
|
||||
|
||||
export type IncidentAnalysisStudent = {
|
||||
student_name?: string
|
||||
grade?: string
|
||||
total?: number
|
||||
open?: number
|
||||
closed?: number
|
||||
canceled?: number
|
||||
last_incident?: string | null
|
||||
logs?: IncidentLogRow[]
|
||||
}
|
||||
|
||||
export type GradeOption = { id: number; name: string }
|
||||
|
||||
function qs(searchParams: URLSearchParams): string {
|
||||
const sy = searchParams.get('school_year')
|
||||
const sem = searchParams.get('semester')
|
||||
const q = new URLSearchParams()
|
||||
if (sy) q.set('school_year', sy)
|
||||
if (sem) q.set('semester', sem)
|
||||
const s = q.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
function unwrapFlags(body: unknown): unknown[] {
|
||||
if (Array.isArray(body)) return body
|
||||
if (body && typeof body === 'object') {
|
||||
const o = body as { flags?: unknown; data?: { flags?: unknown } }
|
||||
const raw = o.flags ?? o.data?.flags
|
||||
if (Array.isArray(raw)) return raw
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function unwrapStudents(body: unknown): unknown[] {
|
||||
if (Array.isArray(body)) return body
|
||||
if (body && typeof body === 'object') {
|
||||
const o = body as { students?: unknown; data?: { students?: unknown } }
|
||||
const raw = o.students ?? o.data?.students
|
||||
if (Array.isArray(raw)) return raw
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export async function fetchFlagsProcessed(searchParams: URLSearchParams): Promise<IncidentFlagRow[]> {
|
||||
const body = await apiFetch<{ flags?: IncidentFlagRow[]; data?: { flags?: IncidentFlagRow[] } }>(
|
||||
`/api/v1/administrator/flags/processed${qs(searchParams)}`,
|
||||
)
|
||||
return unwrapFlags(body) as IncidentFlagRow[]
|
||||
}
|
||||
|
||||
export async function fetchFlagsPending(searchParams: URLSearchParams): Promise<IncidentFlagRow[]> {
|
||||
const body = await apiFetch<{ flags?: IncidentFlagRow[]; data?: { flags?: IncidentFlagRow[] } }>(
|
||||
`/api/v1/administrator/flags/pending${qs(searchParams)}`,
|
||||
)
|
||||
return unwrapFlags(body) as IncidentFlagRow[]
|
||||
}
|
||||
|
||||
export async function fetchIncidentAnalysis(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<IncidentAnalysisStudent[]> {
|
||||
const body = await apiFetch<{
|
||||
students?: IncidentAnalysisStudent[]
|
||||
data?: { students?: IncidentAnalysisStudent[] }
|
||||
}>(`/api/v1/administrator/flags/incident-analysis${qs(searchParams)}`)
|
||||
return unwrapStudents(body) as IncidentAnalysisStudent[]
|
||||
}
|
||||
|
||||
export async function fetchFlagsFormMeta(): Promise<{ grades: GradeOption[] }> {
|
||||
return apiFetch<{ grades: GradeOption[] }>('/api/v1/administrator/flags/form-meta')
|
||||
}
|
||||
|
||||
export async function fetchStudentsByGrade(gradeId: number): Promise<Array<{ id: number; name: string }>> {
|
||||
return apiFetch<Array<{ id: number; name: string }>>(
|
||||
`/api/v1/administrator/flags/grades/${gradeId}/students`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function createIncidentFlag(payload: {
|
||||
grade: number | string
|
||||
student: number | string
|
||||
flag: string
|
||||
description: string
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{ message?: string } | unknown> {
|
||||
return apiFetch(`/api/v1/administrator/flags`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function closeIncidentFlag(
|
||||
id: number,
|
||||
body: { state_description: string; action_taken: string },
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/api/v1/administrator/flags/${id}/close`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function cancelIncidentFlag(
|
||||
id: number,
|
||||
body: { state_description: string; action_taken: string },
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/api/v1/administrator/flags/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Administrator grading API (parity with CI `Views/grading/*.php`).
|
||||
* Base: `/api/v1/grading/...` with JWT.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/grading'
|
||||
|
||||
export type GradingScoreRow = {
|
||||
id?: number
|
||||
score?: number | string | null
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
export type GradingSectionScoreStudent = {
|
||||
student_id: number
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
school_id?: string | null
|
||||
score?: number | string | null
|
||||
scores?: Record<string, number | string | null>
|
||||
comments?: Record<
|
||||
string,
|
||||
{
|
||||
comment?: string | null
|
||||
comment_review?: string | null
|
||||
commented_by?: string | number | null
|
||||
reviewed_by?: string | number | null
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export type GradingScoreFormPayload = {
|
||||
scoresLocked?: boolean
|
||||
student?: { id?: number; firstname?: string; lastname?: string; school_id?: string | null }
|
||||
classSectionId?: number
|
||||
class_section_name?: string | null
|
||||
scores?: GradingScoreRow[]
|
||||
}
|
||||
|
||||
export type GradingSectionScorePayload = {
|
||||
ok?: boolean
|
||||
students?: GradingSectionScoreStudent[]
|
||||
headers?: number[]
|
||||
semester?: string
|
||||
school_year?: string
|
||||
class_section_id?: number | string
|
||||
class_section_name?: string | null
|
||||
scores_locked?: boolean
|
||||
missing_ok_map?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type AllDecisionRow = {
|
||||
student_id: number
|
||||
school_id?: string | null
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
gender?: string | null
|
||||
class_section_id?: number | string | null
|
||||
class_section_name?: string | null
|
||||
fall_score?: number | string | null
|
||||
spring_score?: number | string | null
|
||||
year_score?: number | string | null
|
||||
decision?: string | null
|
||||
source?: string | null
|
||||
notes?: string | null
|
||||
saved?: boolean
|
||||
is_trophy?: boolean
|
||||
}
|
||||
|
||||
export type AllDecisionsPayload = {
|
||||
ok?: boolean
|
||||
rows?: AllDecisionRow[]
|
||||
generated?: boolean
|
||||
school_year?: string
|
||||
}
|
||||
|
||||
function q(sp: URLSearchParams): string {
|
||||
const s = sp.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
export async function fetchGradingMain(searchParams: URLSearchParams): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/overview${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function fetchGradingScoreForm(
|
||||
scoreType: string,
|
||||
classSectionId: string,
|
||||
studentId: string,
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<GradingScoreFormPayload> {
|
||||
return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}/${encodeURIComponent(classSectionId)}/${encodeURIComponent(studentId)}${q(searchParams)}`)
|
||||
}
|
||||
|
||||
function scoreCollectionPath(scoreType: string): string {
|
||||
switch (scoreType) {
|
||||
case 'homework':
|
||||
return '/api/v1/scores/homework'
|
||||
case 'quiz':
|
||||
return '/api/v1/scores/quizzes'
|
||||
case 'project':
|
||||
return '/api/v1/scores/projects'
|
||||
case 'midterm':
|
||||
return '/api/v1/scores/midterm'
|
||||
case 'final':
|
||||
return '/api/v1/scores/final'
|
||||
case 'comments':
|
||||
return '/api/v1/scores/comments'
|
||||
default:
|
||||
throw new Error(`Unsupported score type: ${scoreType}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGradingSectionScoreTable(
|
||||
scoreType: string,
|
||||
classSectionId: string,
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<GradingSectionScorePayload> {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.set('class_section_id', classSectionId)
|
||||
return apiFetch(`${scoreCollectionPath(scoreType)}${q(next)}`)
|
||||
}
|
||||
|
||||
export async function postGradingScoreUpdate(
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/scores`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function postGradingSectionScoreUpdate(
|
||||
scoreType: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const method = scoreType === 'comments' ? 'PUT' : 'POST'
|
||||
return apiFetch(scoreCollectionPath(scoreType), {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchBelowSixty(searchParams: URLSearchParams): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/below-sixty${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function fetchAllDecisions(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<AllDecisionsPayload> {
|
||||
return apiFetch(`${BASE}/decisions${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postGenerateAllDecisions(body: Record<string, unknown>): Promise<{
|
||||
ok?: boolean
|
||||
saved_count?: number
|
||||
}> {
|
||||
return apiFetch(`${BASE}/decisions/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchBelowSixtyDecisions(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/below-sixty/decisions${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postBelowSixtyDecision(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/below-sixty/decisions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchBelowSixtyDecisionDetails(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/below-sixty/decisions/details${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function fetchBelowSixtyDecisionEmailEditor(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<{
|
||||
student_id?: number
|
||||
student_name?: string
|
||||
school_year?: string
|
||||
semester?: string
|
||||
decision?: string
|
||||
subject?: string
|
||||
html?: string
|
||||
year_score?: number | string | null
|
||||
}> {
|
||||
return apiFetch(`${BASE}/below-sixty/decisions/email${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postBelowSixtyDecisionEmail(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/below-sixty/decisions/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchBelowSixtyEmailEditor(searchParams: URLSearchParams): Promise<{
|
||||
studentId?: number
|
||||
studentName?: string
|
||||
semester?: string
|
||||
schoolYear?: string
|
||||
subject?: string
|
||||
html?: string
|
||||
}> {
|
||||
return apiFetch(`${BASE}/below-sixty/email-editor${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postBelowSixtyEmail(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/below-sixty/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function postBelowSixtyStatus(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/below-sixty/status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchHomeworkTracking(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/homework-tracking${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function fetchParticipation(searchParams: URLSearchParams): Promise<unknown> {
|
||||
return apiFetch(`/api/v1/scores/participation${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postParticipation(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch('/api/v1/scores/participation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchPlacementIndex(searchParams: URLSearchParams): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/placement-index${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postPlacementAll(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/placement-all`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchPlacementSection(searchParams: URLSearchParams): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/placement${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postPlacementSection(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/placement`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchPlacementBatch(batchId: string | number): Promise<unknown> {
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchScheduleMeetingForm(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/schedule-meeting${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postScheduleMeeting(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/schedule-meeting`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
|
||||
const TOKEN_KEY = 'alrahma_api_token'
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setStoredToken(token: string | null): void {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function decodeJwtPayload<T = unknown>(token: string): T | null {
|
||||
try {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) return null
|
||||
|
||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
.join(''),
|
||||
)
|
||||
|
||||
return JSON.parse(json) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiHttpError extends Error {
|
||||
readonly status: number
|
||||
readonly body: unknown
|
||||
|
||||
constructor(message: string, status: number, body: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiHttpError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
opts: { attachAuth?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const attachAuth = opts.attachAuth !== false
|
||||
const headers = new Headers(init.headers)
|
||||
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json')
|
||||
}
|
||||
|
||||
const token = getStoredToken()
|
||||
|
||||
if (attachAuth && token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
|
||||
const url = apiUrl(path)
|
||||
|
||||
let res: Response
|
||||
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
} catch (e) {
|
||||
const hint = import.meta.env.DEV
|
||||
? ' Start the API and ensure Vite can reach it. Default proxy is http://localhost:8000 unless VITE_PROXY_API or VITE_API_ORIGIN is set.'
|
||||
: ''
|
||||
|
||||
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) {
|
||||
const msg =
|
||||
typeof body === 'object' && body !== null && 'message' in body
|
||||
? String((body as { message?: unknown }).message ?? '')
|
||||
: ''
|
||||
|
||||
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||
}
|
||||
|
||||
return body as T
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Inventory (parity with CI `Views/inventory/*.php`).
|
||||
* Base: `/api/v1/administrator/inventory` with JWT unless noted.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/inventory'
|
||||
|
||||
function q(searchParams: URLSearchParams): string {
|
||||
const s = searchParams.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
export type InventoryKind = 'book' | 'classroom' | 'kitchen' | 'office'
|
||||
|
||||
/** Index list: expect `{ items, categories, userNames }` (names optional). */
|
||||
export async function fetchInventoryIndex(
|
||||
kind: InventoryKind,
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('type', kind)
|
||||
return apiFetch(`${BASE}/items?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/category`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchInventoryItem(itemId: string | number): Promise<{
|
||||
item?: Record<string, unknown>
|
||||
categories?: Record<string, unknown>[]
|
||||
conditionOptions?: Record<string, string>
|
||||
[key: string]: unknown
|
||||
}> {
|
||||
return apiFetch(`${BASE}/items/${itemId}`)
|
||||
}
|
||||
|
||||
/** Create form payload (categories, defaults). */
|
||||
export async function fetchInventoryCreateForm(
|
||||
kind: InventoryKind,
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/${kind}/create${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postInventoryItem(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function putInventoryItem(
|
||||
itemId: string | number,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/items/${itemId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteInventoryItem(itemId: string | number): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/items/${itemId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function postInventoryAdjust(
|
||||
itemId: string | number,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/items/${itemId}/adjust`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function postClassroomAudit(
|
||||
itemId: string | number,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/items/${itemId}/classroom-audit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchInventorySummary(searchParams: URLSearchParams): Promise<unknown> {
|
||||
const type = searchParams.get('type') || 'classroom'
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('type')
|
||||
const qs = params.toString()
|
||||
return apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function fetchMovements(searchParams: URLSearchParams): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/movements${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function fetchMovementForm(
|
||||
movementId: string | number | undefined,
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
if (movementId === undefined || movementId === '') {
|
||||
return apiFetch(`${BASE}/movements/create${q(searchParams)}`)
|
||||
}
|
||||
return apiFetch(`${BASE}/movements/${movementId}/edit${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postMovement(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/movements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function putMovement(
|
||||
movementId: string | number,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/movements/${movementId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteMovement(movementId: string | number): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/movements/${movementId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function postMovementsBulkDelete(ids: number[]): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/movements/bulk-delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Teacher book distribution — JWT; base matches CI `inventory/books/distribute`. */
|
||||
const TEACHER_DIST = '/api/v1/teacher/inventory/books/distribute'
|
||||
|
||||
export async function fetchTeacherBookDistribute(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${TEACHER_DIST}${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postTeacherBookDistribute(body: Record<string, unknown>): Promise<unknown> {
|
||||
return apiFetch(TEACHER_DIST, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/* ─── New Inventory Improvement API Calls ─── */
|
||||
|
||||
/** Fetch low-stock items (quantity <= reorder_point). */
|
||||
export async function fetchLowStock(): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/low-stock`)
|
||||
}
|
||||
|
||||
/** Fetch reorder suggestions for low-stock items. */
|
||||
export async function fetchReorderSuggestions(): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/reorder-suggestions`)
|
||||
}
|
||||
|
||||
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
|
||||
export async function fetchStockStatus(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/stock-status${q(searchParams)}`)
|
||||
}
|
||||
|
||||
/** Fetch inventory dashboard summary counts. */
|
||||
export async function fetchInventoryDashboard(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/dashboard${q(searchParams)}`)
|
||||
}
|
||||
|
||||
/** Void a movement (append-only correction with reverse entry). */
|
||||
export async function voidMovement(
|
||||
movementId: string | number,
|
||||
reason: string,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/movements/${movementId}/void`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Create a correction movement linked to an existing movement. */
|
||||
export async function correctMovement(
|
||||
movementId: string | number,
|
||||
qtyChange: number,
|
||||
reason: string,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/movements/${movementId}/correct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ qty_change: qtyChange, reason }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
|
||||
*/
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { apiFetch, getStoredToken } from './http'
|
||||
|
||||
const BASE = '/api/v1/finance/invoices'
|
||||
|
||||
function q(searchParams: URLSearchParams): string {
|
||||
const s = searchParams.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
export type InvoiceManagementRow = {
|
||||
parent_id?: number
|
||||
parent_name?: string
|
||||
enrolledKids?: { name?: string; grade?: string | number }[]
|
||||
invoice_id?: number | null
|
||||
invoice_amount?: number
|
||||
refund_amount?: number
|
||||
invoice_date?: string
|
||||
}
|
||||
|
||||
export type InvoiceManagementResponse = {
|
||||
ok?: boolean
|
||||
schoolYears?: string[]
|
||||
schoolYear?: string
|
||||
invoices?: InvoiceManagementRow[]
|
||||
}
|
||||
|
||||
export async function fetchInvoiceManagement(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<InvoiceManagementResponse> {
|
||||
return apiFetch(`${BASE}/management${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function postInvoiceGenerate(parentId: number): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parent_id: parentId }),
|
||||
})
|
||||
}
|
||||
|
||||
/** HTML/JSON preview for browser print view (mirrors FPDF layout). */
|
||||
export async function fetchInvoicePreview(invoiceId: string | number): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/${invoiceId}/preview`)
|
||||
}
|
||||
|
||||
/** 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',
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `PDF HTTP ${res.status}`)
|
||||
}
|
||||
const raw = await res.blob()
|
||||
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
|
||||
}
|
||||
|
||||
export function openPdfBlobInNewTab(blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 120_000)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Password reset / initial set-password flows (legacy CI `user/*` auth views).
|
||||
* Backend expected under `/api/v1/auth/...`.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
export async function requestForgotPassword(email: string): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch('/api/v1/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function submitPasswordReset(payload: {
|
||||
token: string
|
||||
password: string
|
||||
pass_confirm: string
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch('/api/v1/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function submitSetPassword(payload: {
|
||||
user_id: number | string
|
||||
token: string
|
||||
password: string
|
||||
password_confirm: string
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch('/api/v1/auth/set-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function submitAuthorizedUserPassword(
|
||||
userId: number | string,
|
||||
payload: { token: string; password: string; password_confirm: string },
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`/api/v1/auth/set-authorized-user-password/${encodeURIComponent(String(userId))}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Administrator payment / notifications / financial report API.
|
||||
* Paths mirror Laravel `/api/v1/administrator/...` conventions expected by the SPA.
|
||||
*/
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||
|
||||
export type AdminNotificationRow = {
|
||||
id?: number
|
||||
title?: string
|
||||
message?: string
|
||||
priority?: string | number
|
||||
scheduled_at?: string
|
||||
expires_at?: string
|
||||
deleted_at?: string
|
||||
target_group?: string
|
||||
created_at?: string
|
||||
isExpired?: boolean
|
||||
}
|
||||
|
||||
export async function fetchNotificationsDeleted(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{ notifications: AdminNotificationRow[] }> {
|
||||
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}` : ''
|
||||
return apiFetch(`/api/v1/notifications/deleted${suffix}`)
|
||||
}
|
||||
|
||||
export async function fetchNotificationsActive(params?: {
|
||||
target_group?: string
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{ notifications: AdminNotificationRow[] }> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.target_group) qs.set('target_group', params.target_group)
|
||||
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/notifications/active${suffix}`)
|
||||
}
|
||||
|
||||
export async function restoreDeletedNotification(
|
||||
id: number | string,
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`/api/v1/notifications/${encodeURIComponent(String(id))}/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
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}`)
|
||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
typeof body === 'object' && body !== null && 'message' in body
|
||||
? String((body as { message?: unknown }).message ?? '')
|
||||
: ''
|
||||
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||
}
|
||||
return body as T
|
||||
}
|
||||
|
||||
export type ManualPayPageResponse = {
|
||||
ok?: boolean
|
||||
parent?: Record<string, unknown> | null
|
||||
students?: Array<Record<string, unknown>>
|
||||
payments?: Array<Record<string, unknown>>
|
||||
invoices?: Array<Record<string, unknown>>
|
||||
searchTermUsedInSearch?: string
|
||||
installmentEndYmd?: string
|
||||
}
|
||||
|
||||
export async function fetchManualPayPage(params: {
|
||||
search_term: string
|
||||
page?: number | string
|
||||
}): Promise<ManualPayPageResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('search_term', params.search_term)
|
||||
if (params.page != null) qs.set('page', String(params.page))
|
||||
return apiFetch(`/api/v1/finance/manual-pay/search?${qs}`)
|
||||
}
|
||||
|
||||
type ManualPaySuggestRow = { id?: number | string; label?: string; email?: string; phone?: string }
|
||||
|
||||
export async function fetchManualPaySuggest(q: string): Promise<ManualPaySuggestRow[]> {
|
||||
const qs = new URLSearchParams({ q })
|
||||
const body = await apiFetch<{ items?: unknown[]; suggestions?: unknown[]; data?: unknown[] }>(
|
||||
`/api/v1/finance/manual-pay/suggest?${qs}`,
|
||||
)
|
||||
const raw = body.items ?? body.suggestions ?? body.data ?? []
|
||||
return Array.isArray(raw) ? (raw as ManualPaySuggestRow[]) : []
|
||||
}
|
||||
|
||||
export async function submitManualPayUpdate(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||
const invoiceId = String(formData.get('invoice_id') ?? '').trim()
|
||||
if (!invoiceId) {
|
||||
throw new ApiHttpError('Invoice is required.', 422, { errors: { invoice_id: ['Invoice is required.'] } })
|
||||
}
|
||||
const payload = new FormData()
|
||||
const amount = formData.get('amount') ?? formData.get('paid_amount')
|
||||
if (amount != null) payload.set('amount', String(amount))
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'invoice_id' || key === 'paid_amount') continue
|
||||
payload.append(key, value)
|
||||
}
|
||||
return postMultipart(`/api/v1/finance/manual-pay/invoices/${encodeURIComponent(invoiceId)}/payments`, payload)
|
||||
}
|
||||
|
||||
export async function submitManualPayEdit(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||
const paymentId = String(formData.get('payment_id') ?? '').trim()
|
||||
if (!paymentId) {
|
||||
throw new ApiHttpError('Payment is required.', 422, { errors: { payment_id: ['Payment is required.'] } })
|
||||
}
|
||||
formData.delete('payment_id')
|
||||
return apiFetch(`/api/v1/finance/manual-pay/payments/${encodeURIComponent(paymentId)}`, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
/** Authenticated URL for embedding or downloading check/card files (legacy CI: serveCheckFile). */
|
||||
export function manualPayCheckFileUrl(tokenOrKey: string, mode: 'inline' | 'download'): string {
|
||||
const enc = encodeURIComponent(tokenOrKey)
|
||||
return `/api/v1/finance/manual-pay/files/${enc}?mode=${encodeURIComponent(mode)}`
|
||||
}
|
||||
|
||||
export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string> {
|
||||
const headers = new Headers({ Accept: '*/*' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(apiUrl(apiPath), { headers })
|
||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||
const blob = await res.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
export type PaymentNotificationLogRow = Record<string, unknown>
|
||||
|
||||
export async function fetchPaymentNotificationLogs(params?: {
|
||||
from?: string
|
||||
to?: string
|
||||
type?: string
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{
|
||||
logs: PaymentNotificationLogRow[]
|
||||
parentsById?: Record<string, string>
|
||||
filters?: { from?: string; to?: string; type?: string }
|
||||
}> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.from) qs.set('from', params.from)
|
||||
if (params?.to) qs.set('to', params.to)
|
||||
if (params?.type) qs.set('type', params.type)
|
||||
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/notification-logs${suffix}`)
|
||||
}
|
||||
|
||||
export type UnpaidParentRow = Record<string, unknown>
|
||||
|
||||
export async function fetchUnpaidParents(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{ rows: UnpaidParentRow[]; school_year?: string; schoolYears?: string[] }> {
|
||||
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}` : ''
|
||||
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: {
|
||||
school_year: string
|
||||
parent_id?: number | string
|
||||
return_to?: string
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch('/api/v1/administrator/payments/notifications/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export type ParentPaymentsModalRow = Record<string, unknown>
|
||||
|
||||
export async function fetchParentPaymentsForModal(
|
||||
parentId: number | string,
|
||||
): Promise<{ payments: ParentPaymentsModalRow[]; parentName?: string }> {
|
||||
return apiFetch(
|
||||
`/api/v1/administrator/payments/parent/${encodeURIComponent(String(parentId))}/payments-list`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function applyDiscountByCodeForStudent(payload: {
|
||||
student_id: number | string
|
||||
voucher_code: string
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch('/api/v1/discounts/apply-by-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export type ExtraChargeRow = Record<string, unknown>
|
||||
|
||||
export async function fetchExtraChargesPage(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
page?: number | string
|
||||
parent_id?: number | string
|
||||
}): Promise<{
|
||||
rows: ExtraChargeRow[]
|
||||
schoolYear?: string
|
||||
schoolYears?: string[]
|
||||
semester?: string
|
||||
parents?: Array<{ id: number; firstname?: string; lastname?: string }>
|
||||
pager?: { current_page?: number; last_page?: number }
|
||||
}> {
|
||||
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?.page != null) qs.set('page', String(params.page))
|
||||
if (params?.parent_id != null) qs.set('parent_id', String(params.parent_id))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return Promise.all([
|
||||
apiFetch<{
|
||||
rows?: ExtraChargeRow[]
|
||||
school_year?: string
|
||||
semester?: string
|
||||
pager?: { current_page?: number; last_page?: number }
|
||||
}>(`/api/v1/extra-charges${suffix}`),
|
||||
apiFetch<{
|
||||
schoolYear?: string
|
||||
schoolYears?: string[]
|
||||
semester?: string
|
||||
parents?: Array<{ id: number; firstname?: string; lastname?: string }>
|
||||
}>('/api/v1/extra-charges/options'),
|
||||
]).then(([list, options]) => ({
|
||||
rows: Array.isArray(list?.rows) ? list.rows : [],
|
||||
schoolYear: options?.schoolYear ?? list?.school_year,
|
||||
schoolYears: Array.isArray(options?.schoolYears) ? options.schoolYears : [],
|
||||
semester: options?.semester ?? list?.semester,
|
||||
parents: Array.isArray(options?.parents) ? options.parents : [],
|
||||
pager: list?.pager,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function fetchChargeInvoicesForParent(parentId: number | string): Promise<
|
||||
Array<{ id?: number; invoice_number?: string }>
|
||||
> {
|
||||
const body = await apiFetch<{ results?: unknown[] }>(
|
||||
`/api/v1/extra-charges/invoices?parent_id=${encodeURIComponent(String(parentId))}`,
|
||||
)
|
||||
const raw = body.results
|
||||
return Array.isArray(raw) ? (raw as Array<{ id?: number; invoice_number?: string }>) : []
|
||||
}
|
||||
|
||||
export async function submitExtraCharge(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||
return postMultipart('/api/v1/extra-charges', formData)
|
||||
}
|
||||
|
||||
export type FinancialDetailedJson = {
|
||||
ok?: boolean
|
||||
invoices?: Array<Record<string, unknown>>
|
||||
payments?: Array<Record<string, unknown>>
|
||||
refunds?: Array<Record<string, unknown>>
|
||||
discounts?: Array<Record<string, unknown>>
|
||||
paymentBreakdown?: Record<string, { cash?: number; credit?: number; check?: number }>
|
||||
paymentTotals?: {
|
||||
total_cash?: number
|
||||
total_credit?: number
|
||||
total_check?: number
|
||||
total_all?: number
|
||||
}
|
||||
expenses?: Array<{ category?: string; total_amount?: number }>
|
||||
reimbursements?: Array<{ status?: string; total_amount?: number }>
|
||||
eventFeesTotal?: number
|
||||
}
|
||||
|
||||
function unwrapFinancialReport(
|
||||
body: FinancialDetailedJson & { report?: FinancialDetailedJson },
|
||||
): FinancialDetailedJson {
|
||||
if (body && typeof body === 'object' && body.report && typeof body.report === 'object') {
|
||||
return body.report
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export async function fetchFinancialReportDetailed(params: {
|
||||
school_year?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}): Promise<FinancialDetailedJson> {
|
||||
const qs = new URLSearchParams()
|
||||
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)
|
||||
qs.set('format', 'json')
|
||||
const body = await apiFetch<FinancialDetailedJson & { report?: FinancialDetailedJson }>(
|
||||
`/api/v1/finance/financial-report?${qs}`,
|
||||
)
|
||||
return unwrapFinancialReport(body)
|
||||
}
|
||||
|
||||
export async function downloadFinancialReportCsv(params: {
|
||||
school_year?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}): Promise<void> {
|
||||
const qs = new URLSearchParams()
|
||||
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/finance/financial-report/csv?${qs}`
|
||||
const headers = new Headers()
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(apiUrl(path), { headers })
|
||||
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'financial-report.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export type FinancialSummaryJson = {
|
||||
ok?: boolean
|
||||
schoolYear?: string
|
||||
totalCharges?: number
|
||||
totalEventFees?: number
|
||||
totalExtraCharges?: number
|
||||
totalDiscounts?: number
|
||||
totalRefunds?: number
|
||||
totalExpenses?: number
|
||||
totalReimbursements?: number
|
||||
donationToSchool?: number
|
||||
netAmount?: number
|
||||
amountCollected?: number
|
||||
totalUnpaid?: number
|
||||
totalPaid?: number
|
||||
}
|
||||
|
||||
function unwrapFinancialSummary(
|
||||
body: FinancialSummaryJson & {
|
||||
summary?: FinancialSummaryJson
|
||||
schoolYears?: string[]
|
||||
},
|
||||
): FinancialSummaryJson {
|
||||
const summary =
|
||||
body && typeof body === 'object' && body.summary && typeof body.summary === 'object'
|
||||
? body.summary
|
||||
: body
|
||||
|
||||
if (
|
||||
body &&
|
||||
typeof body === 'object' &&
|
||||
Array.isArray(body.schoolYears) &&
|
||||
!Array.isArray((summary as { schoolYears?: unknown }).schoolYears)
|
||||
) {
|
||||
return {
|
||||
...summary,
|
||||
schoolYears: body.schoolYears,
|
||||
} as FinancialSummaryJson
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
export async function fetchFinancialReportSummary(params: {
|
||||
school_year?: string
|
||||
}): Promise<FinancialSummaryJson> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.school_year) qs.set('school_year', params.school_year)
|
||||
qs.set('format', 'json')
|
||||
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/finance/financial-report/pdf?${qs}`
|
||||
const headers = new Headers()
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(apiUrl(path), { 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' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'financial-summary.pdf'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export type PaypalRedirectContext = {
|
||||
parentName?: string
|
||||
totalAmount?: string | number
|
||||
schoolId?: string | number
|
||||
currency?: string
|
||||
invoiceId?: string | number
|
||||
}
|
||||
|
||||
export async function fetchPaypalPaymentContext(params: {
|
||||
invoice_id?: string
|
||||
}): Promise<PaypalRedirectContext> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.invoice_id) qs.set('invoice_id', params.invoice_id)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch(`/api/v1/parent/payment/paypal-context${suffix}`)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Teacher / admin print copy requests (legacy CI: print-requests/*).
|
||||
*/
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||
|
||||
export type PrintRequestRow = Record<string, unknown>
|
||||
|
||||
export type TeacherPrintRequestsContext = {
|
||||
sundays?: string[]
|
||||
times?: string[]
|
||||
class_id?: number | null
|
||||
class_section_id?: number | null
|
||||
class_section_name?: string | null
|
||||
print_requests?: PrintRequestRow[]
|
||||
}
|
||||
|
||||
export async function fetchTeacherPrintRequestsContext(): Promise<TeacherPrintRequestsContext> {
|
||||
return apiFetch('/api/v1/teacher/print-requests/context')
|
||||
}
|
||||
|
||||
async function multipartRequest<T>(path: string, formData: FormData, method = 'POST'): Promise<T> {
|
||||
const headers = new Headers({ Accept: 'application/json' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(apiUrl(path), { method, headers, body: formData })
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
typeof body === 'object' && body !== null && 'message' in body
|
||||
? String((body as { message?: unknown }).message ?? '')
|
||||
: ''
|
||||
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||
}
|
||||
return body as T
|
||||
}
|
||||
|
||||
export async function createPrintRequest(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||
return multipartRequest('/api/v1/print-requests', formData)
|
||||
}
|
||||
|
||||
export async function createCopyRequest(
|
||||
formData: FormData,
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
return multipartRequest('/api/v1/print-requests/hand-copy', formData)
|
||||
}
|
||||
|
||||
export async function updatePrintRequest(
|
||||
requestId: number | string,
|
||||
formData: FormData,
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
formData.set('_method', 'PATCH')
|
||||
return multipartRequest(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}`, formData)
|
||||
}
|
||||
|
||||
export async function deletePrintRequest(requestId: number | string): Promise<{ ok?: boolean }> {
|
||||
return apiFetch(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchAdminPrintRequests(): Promise<{ print_requests: PrintRequestRow[] }> {
|
||||
return apiFetch('/api/v1/administrator/print-requests')
|
||||
}
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/** Authenticated download URL for uploaded job files (opened in new tab). */
|
||||
export function printRequestFileUrl(requestId: number | string): string {
|
||||
return `/api/v1/print-requests/${encodeURIComponent(String(requestId))}/file`
|
||||
}
|
||||
|
||||
export async function openPrintRequestFile(requestId: number | string): Promise<void> {
|
||||
const token = getStoredToken()
|
||||
const headers = new Headers({ Accept: '*/*' })
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
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()
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Stickers, badges, report cards (legacy CI printables_reports/*).
|
||||
*/
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||
|
||||
export type ClassOption = { class_section_id?: number; id?: number; class_section_name?: string }
|
||||
|
||||
export type StudentOption = {
|
||||
id: number | string
|
||||
firstname?: string
|
||||
lastname?: string
|
||||
registration_grade?: string
|
||||
gender?: string
|
||||
}
|
||||
|
||||
export async function fetchStickerFormOptions(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{
|
||||
classes?: ClassOption[]
|
||||
students?: StudentOption[]
|
||||
}> {
|
||||
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}` : ''
|
||||
return apiFetch(`/api/v1/administrator/printables/stickers/form-options${suffix}`)
|
||||
}
|
||||
|
||||
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 ?? []
|
||||
}
|
||||
|
||||
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'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
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' })
|
||||
}
|
||||
|
||||
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}`)
|
||||
const res = await fetch(apiUrl('/api/v1/badges/pdf'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
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' })
|
||||
}
|
||||
|
||||
export type StickerPreviewCounts = {
|
||||
students?: Array<{ student_id: number | string; primary_count?: number }>
|
||||
totals?: { stickers?: number; students?: number }
|
||||
}
|
||||
|
||||
export async function fetchStickerPreviewCounts(classId?: number | string | null): Promise<StickerPreviewCounts> {
|
||||
const qs = new URLSearchParams()
|
||||
if (classId) qs.set('class_id', String(classId))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
const body = await apiFetch<
|
||||
StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts }
|
||||
>(`/api/v1/administrator/printables/stickers/preview${suffix}`)
|
||||
if (body.data) return body.data
|
||||
return body
|
||||
}
|
||||
|
||||
export type BadgeUserRow = Record<string, unknown>
|
||||
|
||||
export async function fetchBadgeFormOptions(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{
|
||||
active_role?: string
|
||||
rolesTabs?: Record<string, string>
|
||||
schoolYears?: string[]
|
||||
selectedYear?: string
|
||||
selectedStudentIds?: number[]
|
||||
selectedUserIds?: number[]
|
||||
users?: BadgeUserRow[]
|
||||
}> {
|
||||
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<{
|
||||
ok?: boolean
|
||||
data?: {
|
||||
active_role?: string
|
||||
rolesTabs?: Record<string, string>
|
||||
schoolYears?: string[]
|
||||
selectedYear?: string
|
||||
selectedStudentIds?: number[]
|
||||
selectedUserIds?: number[]
|
||||
users?: BadgeUserRow[]
|
||||
}
|
||||
} & {
|
||||
active_role?: string
|
||||
rolesTabs?: Record<string, string>
|
||||
schoolYears?: string[]
|
||||
selectedYear?: string
|
||||
selectedStudentIds?: number[]
|
||||
selectedUserIds?: number[]
|
||||
users?: BadgeUserRow[]
|
||||
}>(`/api/v1/administrator/printables/badges/form-options${suffix}`)
|
||||
return body.data ?? body
|
||||
}
|
||||
|
||||
export async function fetchBadgePrintStatus(params: {
|
||||
user_ids?: (number | string)[]
|
||||
student_ids?: (number | string)[]
|
||||
school_year?: string
|
||||
}): Promise<{
|
||||
ok?: boolean
|
||||
data?: Record<string, { count?: number }>
|
||||
csrf_token?: string
|
||||
csrf_hash?: string
|
||||
}> {
|
||||
const qs = new URLSearchParams()
|
||||
for (const id of params.user_ids ?? []) qs.append('user_ids[]', String(id))
|
||||
for (const id of params.student_ids ?? []) qs.append('student_ids[]', String(id))
|
||||
if (params.school_year) qs.set('school_year', params.school_year)
|
||||
return apiFetch(`/api/v1/badges/print-status?${qs}`)
|
||||
}
|
||||
|
||||
export type ReportCardMeta = {
|
||||
ok?: boolean
|
||||
schoolYears?: string[]
|
||||
semesters?: string[]
|
||||
selectedYear?: string
|
||||
selectedSemester?: string
|
||||
students?: Array<{ id: number | string; firstname?: string; lastname?: string; class_section_name?: string }>
|
||||
classSections?: Array<{ class_section_id?: number; id?: number; class_section_name?: string }>
|
||||
}
|
||||
|
||||
export async function fetchReportCardMeta(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<ReportCardMeta> {
|
||||
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}` : ''
|
||||
return apiFetch(`/api/v1/administrator/printables/report-card/meta${suffix}`)
|
||||
}
|
||||
|
||||
export async function fetchReportCardAck(params: {
|
||||
student_id: number | string
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{ ok?: boolean; viewed_at?: string; signed_at?: string; signed_name?: string }> {
|
||||
const qs = new URLSearchParams()
|
||||
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}`)
|
||||
}
|
||||
|
||||
export async function fetchReportCardCompleteness(params: {
|
||||
class_section_id: number | string
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{
|
||||
ok?: boolean
|
||||
students?: Array<Record<string, unknown>>
|
||||
summary?: Record<string, unknown>
|
||||
used_fallback?: boolean
|
||||
}> {
|
||||
const qs = new URLSearchParams()
|
||||
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}`)
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('school_year', opts.school_year)
|
||||
if (opts.semester) qs.set('semester', opts.semester)
|
||||
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}`)
|
||||
}
|
||||
|
||||
export function reportCardClassUrl(
|
||||
classSectionId: number | string,
|
||||
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
|
||||
): string {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('school_year', opts.school_year)
|
||||
if (opts.semester) qs.set('semester', opts.semester)
|
||||
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}`)
|
||||
}
|
||||
|
||||
/** HTML document for iframe preview (Bearer auth; unlike raw iframe src). */
|
||||
export async function fetchReportCardStudentHtml(
|
||||
studentId: number | string,
|
||||
opts: { school_year: string; semester?: string; report_date?: string },
|
||||
): Promise<string> {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('school_year', opts.school_year)
|
||||
if (opts.semester) qs.set('semester', opts.semester)
|
||||
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||
qs.set('cb', String(Date.now()))
|
||||
const headers = new Headers({ Accept: 'text/html' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
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)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
export async function fetchReportCardClassHtml(
|
||||
classSectionId: number | string,
|
||||
opts: { school_year: string; semester?: string; report_date?: string },
|
||||
): Promise<string> {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('school_year', opts.school_year)
|
||||
if (opts.semester) qs.set('semester', opts.semester)
|
||||
if (opts.report_date) qs.set('report_date', opts.report_date)
|
||||
qs.set('cb', String(Date.now()))
|
||||
const headers = new Headers({ Accept: 'text/html' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
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> {
|
||||
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')
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Admin refunds list (legacy CI `refunds/list.php`).
|
||||
* Laravel: `GET /api/v1/finance/refunds` with JWT.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/finance/refunds'
|
||||
|
||||
export type RefundListRow = Record<string, unknown>
|
||||
|
||||
export type RefundsListResponse = {
|
||||
refunds: RefundListRow[]
|
||||
school_year?: string | null
|
||||
semester?: string | null
|
||||
school_years?: string[]
|
||||
}
|
||||
|
||||
function normalizeListPayload(raw: unknown): RefundsListResponse {
|
||||
const body = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
||||
const inner =
|
||||
'data' in body && body.data && typeof body.data === 'object'
|
||||
? (body.data as Record<string, unknown>)
|
||||
: body
|
||||
const list = inner.refunds ?? inner.rows ?? []
|
||||
return {
|
||||
refunds: Array.isArray(list) ? (list as RefundListRow[]) : [],
|
||||
school_year: inner.school_year != null ? String(inner.school_year) : undefined,
|
||||
semester: inner.semester != null ? String(inner.semester) : undefined,
|
||||
school_years: Array.isArray(inner.school_years)
|
||||
? (inner.school_years as string[])
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRefundsList(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
page?: number | string
|
||||
}): Promise<RefundsListResponse> {
|
||||
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?.page != null) qs.set('page', String(params.page))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
const body = await apiFetch<unknown>(`${BASE}${suffix}`)
|
||||
return normalizeListPayload(body)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
|
||||
export type FullRegisterPayload = {
|
||||
firstname: string
|
||||
lastname: string
|
||||
gender: string
|
||||
cellphone: string
|
||||
email: string
|
||||
confirm_email: string
|
||||
address_street: string
|
||||
apt?: string
|
||||
city: string
|
||||
state: string
|
||||
zip: string
|
||||
is_parent: boolean
|
||||
no_second_parent_info: boolean
|
||||
second_firstname?: string
|
||||
second_lastname?: string
|
||||
second_gender?: string
|
||||
second_email?: string
|
||||
second_cellphone?: string
|
||||
accept_school_policy: boolean
|
||||
captcha: string
|
||||
}
|
||||
|
||||
export async function fetchRegisterCaptcha(): Promise<{ challenge: string }> {
|
||||
const res = await fetch(apiUrl('/api/v1/auth/register/captcha'), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok || !body || typeof body !== 'object') {
|
||||
throw new Error('Could not load CAPTCHA.')
|
||||
}
|
||||
const o = body as { ok?: boolean; captcha?: string }
|
||||
if (!o.ok || typeof o.captcha !== 'string') {
|
||||
throw new Error('Invalid CAPTCHA response.')
|
||||
}
|
||||
return { challenge: o.captcha }
|
||||
}
|
||||
|
||||
export type FullRegisterSuccess = {
|
||||
ok: true
|
||||
message?: string
|
||||
registration?: unknown
|
||||
}
|
||||
|
||||
export type FullRegisterFailure = {
|
||||
ok: false
|
||||
message: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export async function submitFullRegistration(
|
||||
payload: FullRegisterPayload,
|
||||
): Promise<FullRegisterSuccess | FullRegisterFailure> {
|
||||
const body: Record<string, unknown> = {
|
||||
firstname: payload.firstname.trim(),
|
||||
lastname: payload.lastname.trim(),
|
||||
gender: payload.gender,
|
||||
cellphone: payload.cellphone.trim(),
|
||||
email: payload.email.trim(),
|
||||
confirm_email: payload.confirm_email.trim(),
|
||||
address_street: payload.address_street.trim(),
|
||||
apt: payload.apt?.trim() || '',
|
||||
city: payload.city.trim(),
|
||||
state: payload.state,
|
||||
zip: payload.zip.trim(),
|
||||
is_parent: payload.is_parent,
|
||||
no_second_parent_info: payload.no_second_parent_info,
|
||||
accept_school_policy: payload.accept_school_policy,
|
||||
captcha: payload.captcha.trim(),
|
||||
}
|
||||
|
||||
if (payload.is_parent && !payload.no_second_parent_info) {
|
||||
body.second_firstname = payload.second_firstname?.trim() ?? ''
|
||||
body.second_lastname = payload.second_lastname?.trim() ?? ''
|
||||
body.second_gender = payload.second_gender ?? ''
|
||||
body.second_email = payload.second_email?.trim() ?? ''
|
||||
body.second_cellphone = payload.second_cellphone?.trim() ?? ''
|
||||
}
|
||||
|
||||
const res = await fetch(apiUrl('/api/v1/auth/register'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data: unknown = await res.json().catch(() => null)
|
||||
|
||||
if (res.ok && data && typeof data === 'object' && 'ok' in data && (data as { ok?: boolean }).ok === true) {
|
||||
return { ok: true, message: (data as { message?: string }).message, registration: (data as { registration?: unknown }).registration }
|
||||
}
|
||||
|
||||
let message = 'Registration failed.'
|
||||
let errors: Record<string, string[]> | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
const o = data as { message?: string; errors?: Record<string, string[] | string> }
|
||||
if (typeof o.message === 'string') message = o.message
|
||||
if (o.errors && typeof o.errors === 'object') {
|
||||
errors = {}
|
||||
for (const [k, v] of Object.entries(o.errors)) {
|
||||
if (Array.isArray(v)) errors[k] = v.map(String)
|
||||
else errors[k] = [String(v)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, message, errors }
|
||||
}
|
||||
|
||||
export async function fetchSchoolPolicyHtml(): Promise<string> {
|
||||
const res = await fetch(apiUrl('/api/v1/policies/school'), {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
data?: { policy?: { content?: string } }
|
||||
}
|
||||
const html = json?.data?.policy?.content
|
||||
if (!res.ok || !html) {
|
||||
return '<p>Unable to load policy. Please contact the school.</p>'
|
||||
}
|
||||
return html
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Admin reimbursements (legacy CI reimbursements/*).
|
||||
* Backend is expected under `/api/v1/administrator/reimbursements`.
|
||||
*/
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
import { ApiHttpError, apiFetch, getStoredToken } from './http'
|
||||
|
||||
const BASE = '/api/v1/administrator/reimbursements'
|
||||
|
||||
export type ReimbursementUserOption = { id?: number | string; firstname?: string; lastname?: string }
|
||||
|
||||
export type BatchSummaryRow = Record<string, unknown>
|
||||
export type BatchDetailPayload = {
|
||||
items?: Array<Record<string, unknown>>
|
||||
checks?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type ReimbursementsIndexResponse = {
|
||||
schoolYears?: string[]
|
||||
users?: ReimbursementUserOption[]
|
||||
batchSummaries?: BatchSummaryRow[]
|
||||
batchDetails?: Record<string, BatchDetailPayload>
|
||||
donationBatch?: BatchSummaryRow | null
|
||||
donationDetails?: BatchDetailPayload
|
||||
batchAttachments?: Record<string, { receipts?: unknown[]; checks?: unknown[] }>
|
||||
}
|
||||
|
||||
export async function fetchReimbursementsIndex(params?: {
|
||||
semester?: string
|
||||
status?: string
|
||||
school_year?: string
|
||||
user_id?: string
|
||||
}): Promise<ReimbursementsIndexResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
if (params?.status) qs.set('status', params.status)
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.user_id) qs.set('user_id', params.user_id)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch(`${BASE}${suffix}`)
|
||||
}
|
||||
|
||||
/** Opens CSV download in a new tab (authenticated GET → blob URL). */
|
||||
export async function downloadReimbursementsExport(params?: {
|
||||
semester?: string
|
||||
status?: string
|
||||
school_year?: string
|
||||
user_id?: string
|
||||
type?: string
|
||||
}): Promise<void> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
if (params?.status) qs.set('status', params.status)
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.user_id) qs.set('user_id', params.user_id)
|
||||
if (params?.type) qs.set('type', params.type)
|
||||
const url = apiUrl(`${BASE}/export?${qs}`)
|
||||
const headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||
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 blob = await res.blob()
|
||||
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
|
||||
}
|
||||
|
||||
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 headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||
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 blob = await res.blob()
|
||||
window.open(URL.createObjectURL(blob), '_blank', 'noopener')
|
||||
}
|
||||
|
||||
export async function postBatchEmail(formData: FormData): Promise<{ success?: boolean; message?: string }> {
|
||||
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 })
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
typeof body === 'object' && body !== null && 'error' in body
|
||||
? String((body as { error?: unknown }).error ?? '')
|
||||
: ''
|
||||
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||
}
|
||||
return body as { success?: boolean; message?: string }
|
||||
}
|
||||
|
||||
export type UnderProcessingWorkspace = {
|
||||
pendingItems?: Array<Record<string, unknown>>
|
||||
itemsPayload?: Array<Record<string, unknown>>
|
||||
existingBatches?: Array<Record<string, unknown>>
|
||||
adminUsers?: Array<{ id?: number; firstname?: string; lastname?: string }>
|
||||
}
|
||||
|
||||
export async function fetchUnderProcessingWorkspace(): Promise<UnderProcessingWorkspace> {
|
||||
return apiFetch(`${BASE}/under-processing`)
|
||||
}
|
||||
|
||||
export async function createReimbursementBatch(): Promise<{
|
||||
success?: boolean
|
||||
batch_id?: number
|
||||
sequence?: number
|
||||
yearly_number?: number
|
||||
csrf_hash?: string
|
||||
}> {
|
||||
const fd = new FormData()
|
||||
return postFormExpectJson(`${BASE}/batch/create`, 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}`)
|
||||
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>
|
||||
if (!res.ok) {
|
||||
throw new ApiHttpError(String(data.error ?? `HTTP ${res.status}`), res.status, body)
|
||||
}
|
||||
if (data && typeof data === 'object' && 'success' in data && data.success === false) {
|
||||
throw new ApiHttpError(String(data.error ?? 'Request failed'), res.status, body)
|
||||
}
|
||||
return data as { success?: boolean; error?: string; csrf_hash?: string; reimbursement_id?: number }
|
||||
}
|
||||
|
||||
export function postBatchUpdate(form: FormData) {
|
||||
return postFormExpectJson(`${BASE}/batch/update`, form)
|
||||
}
|
||||
|
||||
export function postBatchLock(form: FormData) {
|
||||
return postFormExpectJson(`${BASE}/batch/lock`, form)
|
||||
}
|
||||
|
||||
export function postAdminCheckUpload(form: FormData) {
|
||||
return postFormExpectJson(`${BASE}/batch/admin-file/upload`, form)
|
||||
}
|
||||
|
||||
export function postMarkDonation(form: FormData) {
|
||||
return postFormExpectJson(`${BASE}/mark-donation`, form)
|
||||
}
|
||||
|
||||
export type ReimbursementFormContext = {
|
||||
users?: ReimbursementUserOption[]
|
||||
expense_id?: number | string
|
||||
prefill_amount?: number | string
|
||||
}
|
||||
|
||||
export async function fetchReimbursementCreateContext(search: {
|
||||
expense_id?: string
|
||||
}): Promise<ReimbursementFormContext> {
|
||||
const qs = new URLSearchParams()
|
||||
if (search.expense_id) qs.set('expense_id', search.expense_id)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch(`${BASE}/create-context${suffix}`)
|
||||
}
|
||||
|
||||
async function postMultipart(path: string, formData: FormData): Promise<{ ok?: boolean; message?: string }> {
|
||||
const headers = new Headers({ Accept: 'application/json' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: formData })
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
typeof body === 'object' && body !== null && 'message' in body
|
||||
? String((body as { message?: unknown }).message ?? '')
|
||||
: ''
|
||||
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||
}
|
||||
return (body ?? {}) as { ok?: boolean; message?: string }
|
||||
}
|
||||
|
||||
export async function createReimbursement(formData: FormData) {
|
||||
return postMultipart(`${BASE}`, formData)
|
||||
}
|
||||
|
||||
export async function fetchReimbursementForEdit(id: number | string): Promise<{
|
||||
reimbursement?: Record<string, unknown>
|
||||
users?: ReimbursementUserOption[]
|
||||
receipt_url?: string | null
|
||||
}> {
|
||||
return apiFetch(`${BASE}/${encodeURIComponent(String(id))}/edit-data`)
|
||||
}
|
||||
|
||||
export async function updateReimbursement(id: number | string, formData: FormData) {
|
||||
return postMultipart(`${BASE}/${encodeURIComponent(String(id))}`, formData)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { apiFetch } from './http'
|
||||
import type { ApiEnvelope } from './types'
|
||||
|
||||
/**
|
||||
* Laravel `GET /api/v1/reports/combined` with JWT Bearer auth.
|
||||
* If your `routes/api.php` registers this elsewhere, change the path here.
|
||||
*/
|
||||
export type CombinedReportPayload = {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
html?: string
|
||||
message?: string
|
||||
columns?: string[]
|
||||
rows?: Array<Record<string, unknown>>
|
||||
sections?: Array<{
|
||||
title?: string
|
||||
heading?: string
|
||||
rows?: Array<Record<string, unknown>>
|
||||
columns?: string[]
|
||||
}>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type ScorePredictorApiRow = {
|
||||
student_id?: number
|
||||
school_id?: string | null
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
class_section_id?: number | string | null
|
||||
fall_score?: string | number | null
|
||||
spring_score?: string | number | null
|
||||
final_average?: string | number | null
|
||||
required_spring_score?: string | number | null
|
||||
winning_risk?: string | null
|
||||
required_spring_to_pass?: string | number | null
|
||||
failure_risk?: string | null
|
||||
status?: string | null
|
||||
trophy_awarded?: boolean
|
||||
trophy_reason?: string | null
|
||||
}
|
||||
|
||||
type ScorePredictorApiPayload = {
|
||||
ok?: boolean
|
||||
students?: ScorePredictorApiRow[]
|
||||
school_year?: string | null
|
||||
class_sections?: Array<Record<string, unknown>>
|
||||
selected_class_section_id?: number | string | null
|
||||
semester?: string | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
function mapScorePredictorPayload(raw: ScorePredictorApiPayload): CombinedReportPayload {
|
||||
const rows = Array.isArray(raw.students)
|
||||
? raw.students.map((student) => ({
|
||||
school_id: student.school_id ?? '—',
|
||||
firstname: student.firstname ?? '—',
|
||||
lastname: student.lastname ?? '—',
|
||||
class_section_id: student.class_section_id ?? '—',
|
||||
fall_score: student.fall_score ?? 'N/A',
|
||||
spring_score: student.spring_score ?? 'N/A',
|
||||
final_average: student.final_average ?? 'N/A',
|
||||
required_spring_score: student.required_spring_score ?? 'N/A',
|
||||
winning_risk: student.winning_risk ?? '—',
|
||||
required_spring_to_pass: student.required_spring_to_pass ?? 'N/A',
|
||||
failure_risk: student.failure_risk ?? '—',
|
||||
status: student.status ?? '—',
|
||||
trophy_awarded: student.trophy_awarded ? 'Yes' : 'No',
|
||||
trophy_reason: student.trophy_reason ?? '—',
|
||||
}))
|
||||
: []
|
||||
|
||||
const subtitleParts = [
|
||||
raw.school_year ? `School Year: ${raw.school_year}` : null,
|
||||
raw.semester ? `Semester: ${raw.semester}` : null,
|
||||
raw.selected_class_section_id ? `Class Section: ${raw.selected_class_section_id}` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
title: 'Score Analysis',
|
||||
subtitle: subtitleParts.join(' • '),
|
||||
message: raw.message ?? undefined,
|
||||
columns: [
|
||||
'school_id',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'class_section_id',
|
||||
'fall_score',
|
||||
'spring_score',
|
||||
'final_average',
|
||||
'required_spring_score',
|
||||
'winning_risk',
|
||||
'required_spring_to_pass',
|
||||
'failure_risk',
|
||||
'status',
|
||||
'trophy_awarded',
|
||||
'trophy_reason',
|
||||
],
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapCombinedReport(raw: unknown): CombinedReportPayload | null {
|
||||
if (raw == null || typeof raw !== 'object') return null
|
||||
const o = raw as ApiEnvelope<CombinedReportPayload> & CombinedReportPayload
|
||||
if (Array.isArray((raw as ScorePredictorApiPayload).students)) {
|
||||
return mapScorePredictorPayload(raw as ScorePredictorApiPayload)
|
||||
}
|
||||
if (o.data != null && typeof o.data === 'object') {
|
||||
return o.data as CombinedReportPayload
|
||||
}
|
||||
return raw as CombinedReportPayload
|
||||
}
|
||||
|
||||
export async function fetchCombinedReport(params?: {
|
||||
school_year?: string | null
|
||||
semester?: string | null
|
||||
student_id?: string | number | null
|
||||
class_section_id?: string | number | null
|
||||
}): Promise<unknown> {
|
||||
const q = new URLSearchParams()
|
||||
if (params?.school_year) q.set('school_year', String(params.school_year))
|
||||
if (params?.semester) q.set('semester', String(params.semester))
|
||||
if (params?.student_id != null && String(params.student_id).trim() !== '') {
|
||||
q.set('student_id', String(params.student_id))
|
||||
}
|
||||
if (params?.class_section_id != null && String(params.class_section_id).trim() !== '') {
|
||||
q.set('class_section_id', String(params.class_section_id))
|
||||
}
|
||||
const qs = q.size > 0 ? `?${q.toString()}` : ''
|
||||
return apiFetch<unknown>(`/api/v1/scores/predictor${qs}`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { apiFetch } from './http'
|
||||
|
||||
/**
|
||||
* Laravel `GET /api/v1/rfid/coming-soon` with JWT Bearer auth.
|
||||
* Adjust the path if your `routes/api.php` uses a different segment (e.g. under `administrator`).
|
||||
*/
|
||||
export type RfidComingSoonPayload = {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
message?: string
|
||||
/** Extra keys from the API are ignored by the UI unless added explicitly. */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function fetchRfidComingSoon(): Promise<RfidComingSoonPayload> {
|
||||
return apiFetch<RfidComingSoonPayload>('/api/v1/rfid/coming-soon')
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Roles & permissions admin (legacy CI rolepermission/*).
|
||||
* Live Laravel routes are exposed under `/api/v1/role-permissions`.
|
||||
*/
|
||||
import { ApiHttpError, apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/role-permissions'
|
||||
|
||||
type DataEnvelope<T> = { data?: T }
|
||||
|
||||
function unwrapData<T>(body: T | DataEnvelope<T>): T {
|
||||
if (body && typeof body === 'object' && 'data' in body) {
|
||||
const data = (body as DataEnvelope<T>).data
|
||||
if (data !== undefined) return data
|
||||
}
|
||||
return body as T
|
||||
}
|
||||
|
||||
export type RoleRow = {
|
||||
id?: number | string
|
||||
name?: string
|
||||
slug?: string
|
||||
description?: string
|
||||
dashboard_route?: string
|
||||
priority?: number
|
||||
is_active?: number | string | boolean
|
||||
user_count?: number
|
||||
}
|
||||
|
||||
export type PermissionRow = {
|
||||
id?: number | string
|
||||
name?: string
|
||||
description?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export async function fetchRolesList(params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
sort_by?: string
|
||||
sort_dir?: 'asc' | 'desc'
|
||||
}): Promise<{
|
||||
roles?: RoleRow[]
|
||||
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
|
||||
}> {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('per_page', String(params?.per_page ?? 200))
|
||||
if (params?.page) qs.set('page', String(params.page))
|
||||
if (params?.sort_by) qs.set('sort_by', params.sort_by)
|
||||
if (params?.sort_dir) qs.set('sort_dir', params.sort_dir)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
const body = await apiFetch<
|
||||
| {
|
||||
roles?: RoleRow[]
|
||||
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
|
||||
}
|
||||
| {
|
||||
data?: {
|
||||
roles?: RoleRow[]
|
||||
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
|
||||
}
|
||||
}
|
||||
>(`${BASE}/roles${suffix}`)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function fetchAllRoles(): Promise<{ roles: RoleRow[] }> {
|
||||
const rolesById = new Map<string, RoleRow>()
|
||||
let page = 1
|
||||
let lastPage = 1
|
||||
|
||||
do {
|
||||
const response = await fetchRolesList({
|
||||
page,
|
||||
per_page: 200,
|
||||
sort_by: 'name',
|
||||
sort_dir: 'asc',
|
||||
})
|
||||
for (const role of response.roles ?? []) {
|
||||
const key = String(role.id ?? '').trim()
|
||||
if (key === '') {
|
||||
continue
|
||||
}
|
||||
if (!rolesById.has(key)) {
|
||||
rolesById.set(key, role)
|
||||
}
|
||||
}
|
||||
lastPage = Number(response.meta?.last_page ?? page)
|
||||
page += 1
|
||||
} while (page <= lastPage)
|
||||
|
||||
return { roles: [...rolesById.values()] }
|
||||
}
|
||||
|
||||
export async function fetchPermissionsList(): Promise<{ permissions?: PermissionRow[] }> {
|
||||
const body = await apiFetch<
|
||||
{ permissions?: PermissionRow[] } | { data?: { permissions?: PermissionRow[] } }
|
||||
>(`${BASE}/permissions`)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export type AssignUserRow = {
|
||||
id?: number | string
|
||||
account_id?: string
|
||||
firstname?: string
|
||||
lastname?: string
|
||||
email?: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
export async function fetchUsersForAssign(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
page?: number
|
||||
per_page?: number
|
||||
}): Promise<{ users?: AssignUserRow[]; meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number } }> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
qs.set('per_page', String(params?.per_page ?? 200))
|
||||
if (params?.page) qs.set('page', String(params.page))
|
||||
qs.set('sort_by', 'lastname')
|
||||
qs.set('sort_dir', 'asc')
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
const body = await apiFetch<
|
||||
| {
|
||||
users?: AssignUserRow[]
|
||||
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
|
||||
}
|
||||
| {
|
||||
data?: {
|
||||
users?: AssignUserRow[]
|
||||
meta?: { current_page?: number; last_page?: number; total?: number; per_page?: number }
|
||||
}
|
||||
}
|
||||
>(
|
||||
`${BASE}/users${suffix}`,
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function fetchAllUsersForAssign(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{ users: AssignUserRow[] }> {
|
||||
const usersById = new Map<string, AssignUserRow>()
|
||||
let page = 1
|
||||
let lastPage = 1
|
||||
|
||||
do {
|
||||
const response = await fetchUsersForAssign({
|
||||
school_year: params?.school_year,
|
||||
semester: params?.semester,
|
||||
page,
|
||||
per_page: 200,
|
||||
})
|
||||
for (const user of response.users ?? []) {
|
||||
const key = String(user.id ?? '').trim()
|
||||
if (key === '') {
|
||||
continue
|
||||
}
|
||||
if (!usersById.has(key)) {
|
||||
usersById.set(key, user)
|
||||
}
|
||||
}
|
||||
lastPage = Number(response.meta?.last_page ?? page)
|
||||
page += 1
|
||||
} while (page <= lastPage)
|
||||
|
||||
return { users: [...usersById.values()] }
|
||||
}
|
||||
|
||||
export async function saveUserRoles(payload: {
|
||||
user_id: number | string
|
||||
role_ids: (number | string)[]
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
const userId = encodeURIComponent(String(payload.user_id))
|
||||
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||
`${BASE}/users/${userId}/roles`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role_ids: payload.role_ids }),
|
||||
},
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function createRole(payload: Record<string, unknown>): Promise<{ ok?: boolean; message?: string }> {
|
||||
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||
`${BASE}/roles`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
roleId: number | string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
try {
|
||||
const body = await apiFetch<
|
||||
{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }
|
||||
>(`${BASE}/roles/${encodeURIComponent(String(roleId))}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return unwrapData(body)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiHttpError && error.status === 422) {
|
||||
const body = error.body
|
||||
if (body && typeof body === 'object') {
|
||||
const errors = (body as { errors?: unknown }).errors
|
||||
if (errors && typeof errors === 'object') {
|
||||
for (const value of Object.values(errors as Record<string, unknown>)) {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
throw new Error(String(value[0]))
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
throw new Error(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRole(roleId: number | string): Promise<{ ok?: boolean }> {
|
||||
const body = await apiFetch<{ ok?: boolean } | { data?: { ok?: boolean } }>(
|
||||
`${BASE}/roles/${encodeURIComponent(String(roleId))}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function fetchRole(roleId: number | string): Promise<{ role?: RoleRow }> {
|
||||
const body = await apiFetch<{ role?: RoleRow } | { data?: { role?: RoleRow } }>(
|
||||
`${BASE}/roles/${encodeURIComponent(String(roleId))}`,
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function createPermission(payload: {
|
||||
name: string
|
||||
description?: string
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||
`${BASE}/permissions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function fetchPermission(
|
||||
permissionId: number | string,
|
||||
): Promise<{ permission?: PermissionRow }> {
|
||||
const body = await apiFetch<{ permission?: PermissionRow } | { data?: { permission?: PermissionRow } }>(
|
||||
`${BASE}/permissions/${encodeURIComponent(String(permissionId))}`,
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function updatePermission(
|
||||
permissionId: number | string,
|
||||
payload: { name: string; description?: string },
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||
`${BASE}/permissions/${encodeURIComponent(String(permissionId))}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export type PermissionMatrixRow = {
|
||||
id?: number | string
|
||||
permission_id?: number | string
|
||||
name?: string
|
||||
description?: string
|
||||
can_create?: boolean | number
|
||||
can_read?: boolean | number
|
||||
can_update?: boolean | number
|
||||
can_delete?: boolean | number
|
||||
}
|
||||
|
||||
export type ExistingPermissionFlags = {
|
||||
can_create?: boolean
|
||||
can_read?: boolean
|
||||
can_update?: boolean
|
||||
can_delete?: boolean
|
||||
}
|
||||
|
||||
export async function fetchRolePermissionMatrix(roleId: number | string): Promise<{
|
||||
roleId?: number | string
|
||||
permissions?: PermissionMatrixRow[]
|
||||
existing?: Record<string, ExistingPermissionFlags>
|
||||
}> {
|
||||
const body = await apiFetch<
|
||||
| {
|
||||
roleId?: number | string
|
||||
permissions?: PermissionMatrixRow[]
|
||||
existing?: Record<string, ExistingPermissionFlags>
|
||||
}
|
||||
| {
|
||||
data?: {
|
||||
roleId?: number | string
|
||||
permissions?: PermissionMatrixRow[]
|
||||
existing?: Record<string, ExistingPermissionFlags>
|
||||
}
|
||||
}
|
||||
>(`${BASE}/roles/${encodeURIComponent(String(roleId))}/permissions`)
|
||||
return unwrapData(body)
|
||||
}
|
||||
|
||||
export async function saveRolePermissionMatrix(
|
||||
roleId: number | string,
|
||||
payload: Record<string, { create?: boolean; read?: boolean; update?: boolean; delete?: boolean }>,
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
const body = await apiFetch<{ ok?: boolean; message?: string } | { data?: { ok?: boolean; message?: string } }>(
|
||||
`${BASE}/roles/${encodeURIComponent(String(roleId))}/permissions`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ permissions: payload }),
|
||||
},
|
||||
)
|
||||
return unwrapData(body)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { apiFetch } from './http'
|
||||
|
||||
export type SchoolYearStatus = 'draft' | 'active' | 'closed' | 'archived' | string
|
||||
|
||||
export type SchoolYearRecord = {
|
||||
id: number
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
status: SchoolYearStatus
|
||||
is_current: boolean
|
||||
closed_at?: string | null
|
||||
closed_by?: number | null
|
||||
}
|
||||
|
||||
export type SchoolYearSummary = {
|
||||
school_year: SchoolYearRecord
|
||||
totals: {
|
||||
students_considered: number
|
||||
students_blocked: number
|
||||
parents_with_balances: number
|
||||
net_balance_to_transfer: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SchoolYearPromotionRow = {
|
||||
student_id: number
|
||||
student_name: string
|
||||
parent_id?: number | null
|
||||
current_grade?: string | null
|
||||
current_class?: string | null
|
||||
promotion_action: string
|
||||
target_grade?: string | null
|
||||
target_class?: string | null
|
||||
target_class_section_id?: number | null
|
||||
warnings?: string[]
|
||||
decision?: string | null
|
||||
score?: number | null
|
||||
}
|
||||
|
||||
export type SchoolYearPromotionPreview = {
|
||||
rows: SchoolYearPromotionRow[]
|
||||
summary: {
|
||||
total_students: number
|
||||
promote: number
|
||||
repeat: number
|
||||
graduate: number
|
||||
transfer: number
|
||||
withdraw: number
|
||||
hold: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SchoolYearParentBalanceRow = {
|
||||
parent_id: number
|
||||
parent_name?: string | null
|
||||
student_names?: string[]
|
||||
old_unpaid_balance: number
|
||||
credit_balance: number
|
||||
amount_to_transfer: number
|
||||
existing_transfer_conflict?: boolean
|
||||
}
|
||||
|
||||
export type SchoolYearParentBalancePreview = {
|
||||
rows: SchoolYearParentBalanceRow[]
|
||||
summary: {
|
||||
parents_with_non_zero_balance: number
|
||||
parents_with_credit_balance: number
|
||||
total_old_unpaid_balance: number
|
||||
total_credit_balance: number
|
||||
net_balance_to_transfer: number
|
||||
existing_transfer_conflicts: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SchoolYearCloseValidation = {
|
||||
can_close: boolean
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
promotion_summary: SchoolYearPromotionPreview['summary']
|
||||
financial_summary: SchoolYearParentBalancePreview['summary']
|
||||
}
|
||||
|
||||
export type SchoolYearClosePreview = {
|
||||
school_year: SchoolYearRecord
|
||||
validation: SchoolYearCloseValidation
|
||||
promotion_preview: SchoolYearPromotionPreview
|
||||
parent_balances: SchoolYearParentBalancePreview
|
||||
}
|
||||
|
||||
export type SchoolYearCloseResult = {
|
||||
closed_school_year: SchoolYearRecord
|
||||
new_school_year: SchoolYearRecord
|
||||
promotion_counts?: Record<string, number>
|
||||
balance_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
export type SaveSchoolYearPayload = {
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
export type CloseSchoolYearPayload = {
|
||||
new_school_year: SaveSchoolYearPayload
|
||||
transfer_unpaid_balances?: boolean
|
||||
confirmation?: string
|
||||
}
|
||||
|
||||
type ApiListResponse = {
|
||||
ok?: boolean
|
||||
data?: SchoolYearRecord[]
|
||||
}
|
||||
|
||||
type ApiRecordResponse = {
|
||||
ok?: boolean
|
||||
data?: SchoolYearRecord
|
||||
}
|
||||
|
||||
type ApiSummaryResponse = {
|
||||
ok?: boolean
|
||||
data?: SchoolYearSummary
|
||||
}
|
||||
|
||||
type ApiValidationResponse = {
|
||||
ok?: boolean
|
||||
validation?: SchoolYearCloseValidation
|
||||
}
|
||||
|
||||
type ApiPreviewResponse = {
|
||||
ok?: boolean
|
||||
data?: SchoolYearClosePreview
|
||||
}
|
||||
|
||||
type ApiParentBalancesResponse = {
|
||||
ok?: boolean
|
||||
data?: SchoolYearParentBalancePreview
|
||||
}
|
||||
|
||||
type ApiPromotionPreviewResponse = {
|
||||
ok?: boolean
|
||||
data?: SchoolYearPromotionPreview
|
||||
}
|
||||
|
||||
type ApiCloseResponse = {
|
||||
ok?: boolean
|
||||
data?: SchoolYearCloseResult
|
||||
}
|
||||
|
||||
export async function fetchSchoolYears(): Promise<SchoolYearRecord[]> {
|
||||
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
|
||||
return response.data ?? []
|
||||
}
|
||||
|
||||
export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise<SchoolYearRecord> {
|
||||
const response = await apiFetch<ApiRecordResponse>('/api/v1/school-years', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('School year creation returned no record.')
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateSchoolYear(
|
||||
schoolYearId: number,
|
||||
payload: SaveSchoolYearPayload,
|
||||
): Promise<SchoolYearRecord> {
|
||||
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('School year update returned no record.')
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function fetchSchoolYearSummary(schoolYearId: number): Promise<SchoolYearSummary> {
|
||||
const response = await apiFetch<ApiSummaryResponse>(`/api/v1/school-years/${schoolYearId}/summary`)
|
||||
if (!response.data) {
|
||||
throw new Error('School year summary returned no data.')
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function validateSchoolYearClose(
|
||||
schoolYearId: number,
|
||||
payload: CloseSchoolYearPayload,
|
||||
): Promise<SchoolYearCloseValidation> {
|
||||
const response = await apiFetch<ApiValidationResponse>(
|
||||
`/api/v1/school-years/${schoolYearId}/validate-close`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.validation) {
|
||||
throw new Error('School year validation returned no payload.')
|
||||
}
|
||||
|
||||
return response.validation
|
||||
}
|
||||
|
||||
export async function previewSchoolYearClose(
|
||||
schoolYearId: number,
|
||||
payload: CloseSchoolYearPayload,
|
||||
): Promise<SchoolYearClosePreview> {
|
||||
const response = await apiFetch<ApiPreviewResponse>(
|
||||
`/api/v1/school-years/${schoolYearId}/preview-close`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('School year close preview returned no payload.')
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function closeSchoolYear(
|
||||
schoolYearId: number,
|
||||
payload: CloseSchoolYearPayload,
|
||||
): Promise<SchoolYearCloseResult> {
|
||||
const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/${schoolYearId}/close`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('School year close returned no payload.')
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function reopenSchoolYear(schoolYearId: number): Promise<SchoolYearRecord> {
|
||||
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}/reopen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('School year reopen returned no payload.')
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function fetchSchoolYearParentBalances(
|
||||
schoolYearId: number,
|
||||
toSchoolYear?: string,
|
||||
): Promise<SchoolYearParentBalancePreview> {
|
||||
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
|
||||
const response = await apiFetch<ApiParentBalancesResponse>(
|
||||
`/api/v1/school-years/${schoolYearId}/parent-balances${query}`,
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('Parent balances preview returned no payload.')
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function fetchSchoolYearPromotionPreview(
|
||||
schoolYearId: number,
|
||||
toSchoolYear?: string,
|
||||
): Promise<SchoolYearPromotionPreview> {
|
||||
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
|
||||
const response = await apiFetch<ApiPromotionPreviewResponse>(
|
||||
`/api/v1/school-years/${schoolYearId}/promotion-preview${query}`,
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('Promotion preview returned no payload.')
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Score analysis / prediction (legacy CI score_analysis/score_prediction).
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
export type ScorePredictionStudent = Record<string, unknown>
|
||||
|
||||
export type ScorePredictionResponse = {
|
||||
school_year?: string
|
||||
selectedClassSectionId?: string | number | null
|
||||
classSections?: Array<{ class_section_id?: number | string; class_section_name?: string }>
|
||||
students?: ScorePredictionStudent[]
|
||||
}
|
||||
|
||||
export async function fetchScorePrediction(params?: {
|
||||
school_year?: string
|
||||
class_section_id?: string
|
||||
}): Promise<ScorePredictionResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.class_section_id) qs.set('class_section_id', params.class_section_id)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch(`/api/v1/administrator/score-analysis/score-prediction${suffix}`)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Late slips preview (legacy CI slips/preview_list).
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
export type SlipPreviewRow = Record<string, unknown>
|
||||
|
||||
export async function fetchSlipPreviewList(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<{ rows?: SlipPreviewRow[]; school_year?: string; semester?: string }> {
|
||||
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 res = await apiFetch<{ ok: boolean; logs?: SlipPreviewRow[] }>(
|
||||
`/api/v1/reports/slips/logs${suffix}`,
|
||||
)
|
||||
return { rows: res.logs ?? [] }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Staff directory (legacy CI `staff/*`).
|
||||
* Expected Laravel routes under `/api/v1/administrator/staff`.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/administrator/staff'
|
||||
|
||||
export type StaffListRow = {
|
||||
id: number
|
||||
user_id?: number
|
||||
school_id?: string | null
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
active_role?: string | null
|
||||
class_section?: string | null
|
||||
verification_issue?: boolean
|
||||
}
|
||||
|
||||
export type StaffIndexResponse = {
|
||||
staff?: StaffListRow[]
|
||||
issues_count?: number
|
||||
school_year?: string | null
|
||||
semester?: string | null
|
||||
schoolYears?: string[]
|
||||
}
|
||||
|
||||
export type RoleOption = { id: number; name: string }
|
||||
|
||||
export type StaffFormOptionsResponse = {
|
||||
roles?: RoleOption[]
|
||||
}
|
||||
|
||||
export type StaffMemberResponse = {
|
||||
user?: Record<string, unknown> & {
|
||||
id?: number
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
staff?: Record<string, unknown> & {
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchStaffIndex(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): 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)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch(`${BASE}${suffix}`)
|
||||
}
|
||||
|
||||
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> {
|
||||
return apiFetch(`${BASE}/form-options`)
|
||||
}
|
||||
|
||||
export async function fetchStaffMember(id: number): Promise<StaffMemberResponse> {
|
||||
return apiFetch(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export async function createStaff(payload: {
|
||||
firstname: string
|
||||
lastname: string
|
||||
email: string
|
||||
password: string
|
||||
role_id: number
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`${BASE}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateStaff(
|
||||
id: number,
|
||||
payload: { firstname: string; lastname: string; email: string; phone?: string },
|
||||
): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`${BASE}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Teacher portal API (`Views/teacher/*` parity).
|
||||
* Laravel should expose matching routes under `/api/v1/teacher/...`.
|
||||
*/
|
||||
import { apiFetch, getStoredToken } from './http'
|
||||
import { apiUrl } from '../lib/apiOrigin'
|
||||
|
||||
/** Response shape for `GET /api/v1/teacher/dashboard` (extend as backend grows). */
|
||||
export type TeacherDashboardPayload = {
|
||||
welcome?: string | null
|
||||
school_year?: string | null
|
||||
semester?: string | null
|
||||
counts?: Record<string, number | string | null>
|
||||
announcements?: Array<{ title?: string | null; body?: string | null; html?: string | null }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function fetchTeacherJson<T>(path: string): Promise<T> {
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return apiFetch<T>(`/api/v1/teacher${p}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in: set `VITE_TEACHER_DASHBOARD_API=1` in `.env` when Laravel exposes
|
||||
* `GET /api/v1/teacher/dashboard`. Otherwise the SPA skips the request (avoids 404 noise).
|
||||
*/
|
||||
export function isTeacherDashboardApiEnabled(): boolean {
|
||||
return import.meta.env.VITE_TEACHER_DASHBOARD_API === '1'
|
||||
}
|
||||
|
||||
export async function fetchTeacherDashboard(): Promise<TeacherDashboardPayload> {
|
||||
return fetchTeacherJson<TeacherDashboardPayload>('/dashboard')
|
||||
}
|
||||
|
||||
export async function postTeacherJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return apiFetch<T>(`/api/v1/teacher${p}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function postTeacherMultipart<T>(path: string, formData: FormData): Promise<T> {
|
||||
const isAbsoluteApiPath = path.startsWith('/api/')
|
||||
const p = isAbsoluteApiPath ? path : path.startsWith('/') ? path : `/${path}`
|
||||
const headers = new Headers({ Accept: 'application/json' })
|
||||
const token = getStoredToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
const url = isAbsoluteApiPath ? apiUrl(p) : apiUrl(`/api/v1/teacher${p}`)
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
let msg =
|
||||
typeof body === 'object' && body !== null && 'message' in body
|
||||
? String((body as { message?: unknown }).message ?? '')
|
||||
: ''
|
||||
|
||||
if (typeof body === 'object' && body !== null && 'errors' in body) {
|
||||
const errors = (body as { errors?: unknown }).errors
|
||||
if (errors && typeof errors === 'object') {
|
||||
const firstFieldErrors = Object.values(errors as Record<string, unknown>).find(
|
||||
(value) => Array.isArray(value) && value.length > 0,
|
||||
)
|
||||
if (Array.isArray(firstFieldErrors) && firstFieldErrors[0]) {
|
||||
msg = String(firstFieldErrors[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(msg || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return body as T
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* WhatsApp admin tools (legacy CI `whatsapp/*`).
|
||||
* Expected Laravel routes under `/api/v1/whatsapp`.
|
||||
*/
|
||||
import { apiFetch } from './http'
|
||||
|
||||
const BASE = '/api/v1/whatsapp'
|
||||
|
||||
export type WhatsappSectionRow = {
|
||||
class_section_id?: number
|
||||
id?: number
|
||||
class_section_name?: string
|
||||
name?: string
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}
|
||||
|
||||
export type WhatsappLinkRow = {
|
||||
invite_link?: string | null
|
||||
active?: number | boolean
|
||||
}
|
||||
|
||||
export type WhatsappManageLinksResponse = {
|
||||
schoolYear?: string | null
|
||||
semester?: string | null
|
||||
sections?: WhatsappSectionRow[]
|
||||
linksBySection?: Record<string, WhatsappLinkRow>
|
||||
parents?: Array<{
|
||||
id: number
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
email?: string | null
|
||||
source?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export type ParentContactRow = {
|
||||
firstname?: string | null
|
||||
lastname?: string | null
|
||||
type?: string | null
|
||||
phone?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
export type WhatsappParentContactsResponse = {
|
||||
schoolYear?: string | null
|
||||
semester?: string | null
|
||||
contacts?: ParentContactRow[]
|
||||
}
|
||||
|
||||
export type ParentContactsByClassParentRow = {
|
||||
class_section_id?: number
|
||||
primary_id?: number
|
||||
primary_name?: string
|
||||
primary_phone?: string | null
|
||||
primary_member?: string | null
|
||||
second_id?: number
|
||||
second_name?: string
|
||||
second_phone?: string | null
|
||||
second_member?: string | null
|
||||
}
|
||||
|
||||
export type ParentContactsByClassSection = {
|
||||
class_section_id?: number
|
||||
class_section_name?: string
|
||||
school_year?: string
|
||||
semester?: string
|
||||
parents?: ParentContactsByClassParentRow[]
|
||||
}
|
||||
|
||||
export type WhatsappParentContactsByClassResponse = {
|
||||
classSections?: ParentContactsByClassSection[]
|
||||
}
|
||||
|
||||
export async function fetchWhatsappManageLinks(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<WhatsappManageLinksResponse> {
|
||||
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}` : ''
|
||||
return apiFetch(`${BASE}/links${suffix}`)
|
||||
}
|
||||
|
||||
export async function saveWhatsappLink(payload: {
|
||||
class_section_id: number
|
||||
class_section_name?: string
|
||||
invite_link: string
|
||||
active?: boolean
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`${BASE}/links`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendWhatsappInvites(payload: {
|
||||
mode: 'all' | 'class' | 'parents'
|
||||
class_section_id?: number | null
|
||||
parent_ids?: number[]
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`${BASE}/invites/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchWhatsappParentContacts(params?: {
|
||||
school_year?: string
|
||||
semester?: string
|
||||
}): Promise<WhatsappParentContactsResponse> {
|
||||
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}` : ''
|
||||
return apiFetch(`${BASE}/parent-contacts${suffix}`)
|
||||
}
|
||||
|
||||
export async function fetchWhatsappParentContactsByClass(): Promise<WhatsappParentContactsByClassResponse> {
|
||||
return apiFetch(`${BASE}/parent-contacts-by-class`)
|
||||
}
|
||||
|
||||
export async function updateWhatsappMembership(payload: {
|
||||
class_section_id: number
|
||||
school_year?: string
|
||||
semester?: string
|
||||
primary_id: number
|
||||
second_id?: number
|
||||
primary_member?: string
|
||||
second_member?: string
|
||||
}): Promise<{ status?: string; message?: string }> {
|
||||
return apiFetch(`${BASE}/membership`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import { ApiHttpError, setStoredToken, getStoredToken } from '../api/http'
|
||||
import type { AuthUser } from '../api/types'
|
||||
import { loginRequest } from '../api/session'
|
||||
|
||||
export type { AuthUser }
|
||||
|
||||
type AuthState = {
|
||||
token: string | null
|
||||
user: AuthUser | null
|
||||
selectedRole: string | null
|
||||
}
|
||||
|
||||
const STORAGE_USER = 'alrahma_api_user'
|
||||
const STORAGE_ROLE = 'alrahma_selected_role'
|
||||
|
||||
function enabledRoles(user: AuthUser | null): string[] {
|
||||
if (!user?.roles) return []
|
||||
return Object.keys(user.roles).filter((k) => user.roles[k])
|
||||
}
|
||||
|
||||
function readUser(): AuthUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_USER)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as AuthUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeUser(user: AuthUser | null): void {
|
||||
if (user) localStorage.setItem(STORAGE_USER, JSON.stringify(user))
|
||||
else localStorage.removeItem(STORAGE_USER)
|
||||
}
|
||||
|
||||
function readSelectedRole(): string | null {
|
||||
const role = localStorage.getItem(STORAGE_ROLE)
|
||||
return role && role.trim() ? role : null
|
||||
}
|
||||
|
||||
function writeSelectedRole(role: string | null): void {
|
||||
if (role) localStorage.setItem(STORAGE_ROLE, role)
|
||||
else localStorage.removeItem(STORAGE_ROLE)
|
||||
}
|
||||
|
||||
function validSelectedRole(user: AuthUser | null, role: string | null): string | null {
|
||||
if (!role) return null
|
||||
const roles = enabledRoles(user)
|
||||
return roles.includes(role) ? role : null
|
||||
}
|
||||
|
||||
let memory: AuthState = {
|
||||
token: getStoredToken(),
|
||||
user: readUser(),
|
||||
selectedRole: null,
|
||||
}
|
||||
|
||||
memory.selectedRole = validSelectedRole(memory.user, readSelectedRole())
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((fn) => fn())
|
||||
}
|
||||
|
||||
function setAuth(next: AuthState): void {
|
||||
memory = next
|
||||
emit()
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
function getSnapshot(): AuthState {
|
||||
return memory
|
||||
}
|
||||
|
||||
export function hydrateAuthFromStorage(): void {
|
||||
const user = readUser()
|
||||
memory = {
|
||||
token: getStoredToken(),
|
||||
user,
|
||||
selectedRole: validSelectedRole(user, readSelectedRole()),
|
||||
}
|
||||
emit()
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
let result: Awaited<ReturnType<typeof loginRequest>>
|
||||
try {
|
||||
result = await loginRequest(email, password)
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof ApiHttpError
|
||||
? e.message
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: 'Could not reach the server.'
|
||||
return { ok: false as const, message }
|
||||
}
|
||||
|
||||
if (result.status === false) {
|
||||
return {
|
||||
ok: false as const,
|
||||
message: result.message ?? 'Login failed.',
|
||||
}
|
||||
}
|
||||
|
||||
const roles = enabledRoles(result.user)
|
||||
const nextRole = roles.length === 1 ? roles[0] : null
|
||||
setStoredToken(result.token)
|
||||
writeUser(result.user)
|
||||
writeSelectedRole(nextRole)
|
||||
setAuth({ token: result.token, user: result.user, selectedRole: nextRole })
|
||||
return {
|
||||
ok: true as const,
|
||||
requiresRoleSelection: roles.length > 1,
|
||||
roles,
|
||||
selectedRole: nextRole,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setStoredToken(null)
|
||||
writeUser(null)
|
||||
writeSelectedRole(null)
|
||||
setAuth({ token: null, user: null, selectedRole: null })
|
||||
}, [])
|
||||
|
||||
const setSession = useCallback((token: string, user: AuthUser) => {
|
||||
const roles = enabledRoles(user)
|
||||
const nextRole = roles.length === 1 ? roles[0] : null
|
||||
setStoredToken(token)
|
||||
writeUser(user)
|
||||
writeSelectedRole(nextRole)
|
||||
setAuth({ token, user, selectedRole: nextRole })
|
||||
}, [])
|
||||
|
||||
const setSelectedRole = useCallback((role: string | null) => {
|
||||
const roles = enabledRoles(memory.user)
|
||||
const nextRole = role && roles.includes(role) ? role : null
|
||||
writeSelectedRole(nextRole)
|
||||
setAuth({ ...memory, selectedRole: nextRole })
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
login,
|
||||
logout,
|
||||
setSession,
|
||||
setSelectedRole,
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
selectedRole: state.selectedRole,
|
||||
roles: enabledRoles(state.user),
|
||||
isAuthenticated: Boolean(state.token && state.user),
|
||||
}),
|
||||
[state.token, state.user, state.selectedRole, login, logout, setSession, setSelectedRole],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
type AuthContextValue = {
|
||||
login: (
|
||||
email: string,
|
||||
password: string,
|
||||
) => Promise<
|
||||
{ ok: true; requiresRoleSelection: boolean; roles: string[]; selectedRole: string | null } | { ok: false; message: string }
|
||||
>
|
||||
logout: () => void
|
||||
setSession: (token: string, user: AuthUser) => void
|
||||
setSelectedRole: (role: string | null) => void
|
||||
token: string | null
|
||||
user: AuthUser | null
|
||||
selectedRole: string | null
|
||||
roles: string[]
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from './AuthProvider'
|
||||
|
||||
export function RequireAuth() {
|
||||
const { isAuthenticated, roles, selectedRole } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
}
|
||||
|
||||
const needsRoleSelection = roles.length > 1 && !selectedRole
|
||||
if (needsRoleSelection && location.pathname !== '/app/select-role') {
|
||||
return <Navigate to="/app/select-role" replace state={{ from: location.pathname }} />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type SortDirection = 'ascending' | 'descending'
|
||||
|
||||
type TableSortState = {
|
||||
columnIndex: number
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
const ACTION_HEADER_PATTERN = /^(action|actions|option|options|edit|delete|remove|preview|print|open|link)$/i
|
||||
const CUSTOM_SORT_SELECTOR = [
|
||||
'thead th[role="button"]',
|
||||
'thead th button',
|
||||
'thead th a',
|
||||
'thead th input',
|
||||
'thead th select',
|
||||
'thead th textarea',
|
||||
].join(', ')
|
||||
|
||||
function headerText(cell: HTMLTableCellElement): string {
|
||||
return (cell.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function parseBooleanValue(value: string): number | null {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized) return null
|
||||
if (['yes', 'true', 'active', 'enabled', 'paid', 'present'].includes(normalized)) return 1
|
||||
if (['no', 'false', 'inactive', 'disabled', 'unpaid', 'absent'].includes(normalized)) return 0
|
||||
return null
|
||||
}
|
||||
|
||||
function parseNumberValue(value: string): number | null {
|
||||
const normalized = value.trim()
|
||||
if (!normalized) return null
|
||||
if (!/^\(?\s*[$€£]?\s*-?[\d,.]+%?\s*\)?$/.test(normalized)) return null
|
||||
|
||||
const negative = normalized.includes('(') && normalized.includes(')')
|
||||
const cleaned = normalized.replace(/[%,$€£()\s]/g, '').replace(/,/g, '')
|
||||
if (!cleaned || cleaned === '-' || cleaned === '.' || cleaned === '-.') return null
|
||||
|
||||
const parsed = Number(cleaned)
|
||||
if (!Number.isFinite(parsed)) return null
|
||||
return negative ? -parsed : parsed
|
||||
}
|
||||
|
||||
function parseDateValue(value: string): number | null {
|
||||
const normalized = value.trim()
|
||||
if (!normalized) return null
|
||||
const parsed = Date.parse(normalized)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
function cellSortValue(cell: HTMLTableCellElement | null): string {
|
||||
if (!cell) return ''
|
||||
const dataValue = cell.getAttribute('data-sort-value')
|
||||
if (dataValue != null) return dataValue.trim()
|
||||
return (cell.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function getLogicalCellForColumn(row: HTMLTableRowElement, columnIndex: number): HTMLTableCellElement | null {
|
||||
let cursor = 0
|
||||
for (const cell of Array.from(row.cells)) {
|
||||
const span = Math.max(1, cell.colSpan || 1)
|
||||
if (columnIndex >= cursor && columnIndex < cursor + span) {
|
||||
return cell
|
||||
}
|
||||
cursor += span
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function compareValues(left: string, right: string): number {
|
||||
const leftBoolean = parseBooleanValue(left)
|
||||
const rightBoolean = parseBooleanValue(right)
|
||||
if (leftBoolean != null && rightBoolean != null) return leftBoolean - rightBoolean
|
||||
|
||||
const leftNumber = parseNumberValue(left)
|
||||
const rightNumber = parseNumberValue(right)
|
||||
if (leftNumber != null && rightNumber != null) return leftNumber - rightNumber
|
||||
|
||||
const leftDate = parseDateValue(left)
|
||||
const rightDate = parseDateValue(right)
|
||||
if (leftDate != null && rightDate != null) return leftDate - rightDate
|
||||
|
||||
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function tableSupportsAutoSort(table: HTMLTableElement): boolean {
|
||||
if (table.classList.contains('ci-no-auto-sort')) return false
|
||||
if (table.querySelector(CUSTOM_SORT_SELECTOR)) return false
|
||||
if (table.querySelector('tbody td[rowspan], tbody th[rowspan]')) return false
|
||||
|
||||
const head = table.tHead
|
||||
const headerRow = head?.rows.item(head.rows.length - 1)
|
||||
if (!headerRow || headerRow.cells.length === 0) return false
|
||||
|
||||
const bodyRows = Array.from(table.tBodies).flatMap((body) => Array.from(body.rows))
|
||||
if (bodyRows.length < 2) return false
|
||||
|
||||
return !Array.from(headerRow.cells).some((cell) => window.getComputedStyle(cell).cursor === 'pointer')
|
||||
}
|
||||
|
||||
function updateHeaderState(
|
||||
headers: Array<{ cell: HTMLTableCellElement; columnIndex: number }>,
|
||||
sortState: TableSortState | undefined,
|
||||
) {
|
||||
for (const { cell, columnIndex } of headers) {
|
||||
const active = sortState?.columnIndex === columnIndex
|
||||
const direction = active ? sortState?.direction : null
|
||||
cell.dataset.ciSortDirection =
|
||||
direction === 'ascending' ? 'ascending' : direction === 'descending' ? 'descending' : 'none'
|
||||
cell.setAttribute('aria-sort', direction ?? 'none')
|
||||
}
|
||||
}
|
||||
|
||||
function sortTableRows(table: HTMLTableElement, columnIndex: number, direction: SortDirection) {
|
||||
const factor = direction === 'ascending' ? 1 : -1
|
||||
for (const body of Array.from(table.tBodies)) {
|
||||
const rows = Array.from(body.rows)
|
||||
if (rows.length < 2) continue
|
||||
|
||||
const sortedRows = [...rows].sort((left, right) => {
|
||||
const leftValue = cellSortValue(getLogicalCellForColumn(left, columnIndex))
|
||||
const rightValue = cellSortValue(getLogicalCellForColumn(right, columnIndex))
|
||||
return compareValues(leftValue, rightValue) * factor
|
||||
})
|
||||
|
||||
for (const row of sortedRows) {
|
||||
body.appendChild(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function GlobalTableSorting() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const enhancedHeaders = new WeakSet<HTMLTableCellElement>()
|
||||
const tableSortStates = new WeakMap<HTMLTableElement, TableSortState>()
|
||||
const cleanupCallbacks = new WeakMap<HTMLTableCellElement, () => void>()
|
||||
let applyingSort = false
|
||||
let frameId = 0
|
||||
|
||||
function bindTables() {
|
||||
frameId = 0
|
||||
|
||||
for (const table of Array.from(document.querySelectorAll('table'))) {
|
||||
if (!(table instanceof HTMLTableElement)) continue
|
||||
if (!tableSupportsAutoSort(table)) continue
|
||||
|
||||
const head = table.tHead
|
||||
const headerRow = head?.rows.item(head.rows.length - 1)
|
||||
if (!headerRow) continue
|
||||
|
||||
const headers: Array<{ cell: HTMLTableCellElement; columnIndex: number }> = []
|
||||
let columnCursor = 0
|
||||
|
||||
for (const headerCell of Array.from(headerRow.cells)) {
|
||||
const span = Math.max(1, headerCell.colSpan || 1)
|
||||
const label = headerText(headerCell)
|
||||
const columnIndex = columnCursor
|
||||
const isActionColumn = ACTION_HEADER_PATTERN.test(label)
|
||||
columnCursor += span
|
||||
|
||||
if (span !== 1 || isActionColumn) {
|
||||
headerCell.classList.remove('ci-sortable-column')
|
||||
headerCell.removeAttribute('tabindex')
|
||||
headerCell.removeAttribute('aria-sort')
|
||||
delete headerCell.dataset.ciSortDirection
|
||||
continue
|
||||
}
|
||||
|
||||
headers.push({ cell: headerCell, columnIndex })
|
||||
|
||||
if (enhancedHeaders.has(headerCell)) continue
|
||||
|
||||
enhancedHeaders.add(headerCell)
|
||||
headerCell.classList.add('ci-sortable-column')
|
||||
headerCell.tabIndex = 0
|
||||
headerCell.setAttribute('aria-label', label ? `Sort by ${label}` : 'Sort table')
|
||||
|
||||
const activateSort = () => {
|
||||
const current = tableSortStates.get(table)
|
||||
const nextDirection: SortDirection =
|
||||
current?.columnIndex === columnIndex && current.direction === 'ascending'
|
||||
? 'descending'
|
||||
: 'ascending'
|
||||
|
||||
const nextState = { columnIndex, direction: nextDirection }
|
||||
tableSortStates.set(table, nextState)
|
||||
updateHeaderState(headers, nextState)
|
||||
|
||||
applyingSort = true
|
||||
sortTableRows(table, columnIndex, nextDirection)
|
||||
queueMicrotask(() => {
|
||||
applyingSort = false
|
||||
})
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
activateSort()
|
||||
}
|
||||
|
||||
headerCell.addEventListener('click', activateSort)
|
||||
headerCell.addEventListener('keydown', onKeyDown)
|
||||
|
||||
cleanupCallbacks.set(headerCell, () => {
|
||||
headerCell.removeEventListener('click', activateSort)
|
||||
headerCell.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
}
|
||||
|
||||
const sortState = tableSortStates.get(table)
|
||||
updateHeaderState(headers, sortState)
|
||||
|
||||
if (sortState) {
|
||||
applyingSort = true
|
||||
sortTableRows(table, sortState.columnIndex, sortState.direction)
|
||||
queueMicrotask(() => {
|
||||
applyingSort = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleBind = () => {
|
||||
if (frameId !== 0) return
|
||||
frameId = window.requestAnimationFrame(bindTables)
|
||||
}
|
||||
|
||||
bindTables()
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (applyingSort) return
|
||||
scheduleBind()
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (frameId !== 0) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
|
||||
for (const table of Array.from(document.querySelectorAll('table'))) {
|
||||
if (!(table instanceof HTMLTableElement)) continue
|
||||
const head = table.tHead
|
||||
const headerRow = head?.rows.item(head.rows.length - 1)
|
||||
if (!headerRow) continue
|
||||
|
||||
for (const headerCell of Array.from(headerRow.cells)) {
|
||||
cleanupCallbacks.get(headerCell)?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
export const NE_STATES: { abbr: string; name: string }[] = [
|
||||
{ abbr: 'CT', name: 'Connecticut' },
|
||||
{ abbr: 'ME', name: 'Maine' },
|
||||
{ abbr: 'MA', name: 'Massachusetts' },
|
||||
{ abbr: 'NH', name: 'New Hampshire' },
|
||||
{ abbr: 'NY', name: 'New York' },
|
||||
{ abbr: 'RI', name: 'Rhode Island' },
|
||||
{ abbr: 'VT', name: 'Vermont' },
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchDashboardRoute, postRoleSwitch } from '../api/session'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
|
||||
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
|
||||
|
||||
/** Shared navigation after JWT login when the account has multiple roles (CI `welcome_back` / `select_role`). */
|
||||
export function useContinueWithRole() {
|
||||
const { setSelectedRole } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [busyRole, setBusyRole] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const from = (location.state as { from?: string } | null)?.from ?? '/app/home'
|
||||
|
||||
async function continueWithRole(nextRole: string) {
|
||||
setBusy(true)
|
||||
setBusyRole(nextRole)
|
||||
setError(null)
|
||||
try {
|
||||
const fromApp = from.startsWith('/app') ? from : ''
|
||||
const hasDeepLink = Boolean(fromApp && fromApp !== '/app/home')
|
||||
|
||||
let target: string | null = null
|
||||
try {
|
||||
const switched = await postRoleSwitch(nextRole)
|
||||
const route = switched.data?.dashboard_route
|
||||
const mapped = route ? spaPathFromCiUrl(route) : null
|
||||
if (mapped) {
|
||||
target = mapped
|
||||
}
|
||||
} catch {
|
||||
/* fall back to client-side role defaults */
|
||||
}
|
||||
|
||||
setSelectedRole(nextRole)
|
||||
|
||||
if (hasDeepLink) {
|
||||
target = fromApp
|
||||
} else if (isParentPortalRole(nextRole)) {
|
||||
target = '/app/parent/home'
|
||||
} else if (isTeacherPortalRole(nextRole)) {
|
||||
target = '/app/teacher_dashboard'
|
||||
} else if (!target) {
|
||||
target = '/app/home'
|
||||
try {
|
||||
const dash = await fetchDashboardRoute()
|
||||
const route = dash.data?.dashboard?.route
|
||||
const mapped = spaPathFromCiUrl(route)
|
||||
if (mapped) target = mapped
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
}
|
||||
|
||||
navigate(target ?? '/app/home', { replace: true })
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to continue.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setBusyRole(null)
|
||||
}
|
||||
}
|
||||
|
||||
return { continueWithRole, busy, busyRole, error, setError, from }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||