Compare commits

..

42 Commits

Author SHA1 Message Date
root c6d2bc007e fix first production issues
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 40s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 24s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-10 03:43:47 -04:00
root b895c06dc1 fix parent pages and teachers and admin
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-09 13:56:26 -04:00
root 4b78aafa65 fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 30s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-07 01:52:37 -04:00
root b510f534fd security fix and fix parent pages
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 22s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 32s
2026-07-06 02:14:22 -04:00
root b74ddca8b1 add school year and security fix
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 28s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
2026-07-06 00:55:02 -04:00
root 8cacd0874f fix calendar page
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 32s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 47s
2026-07-05 14:18:49 -04:00
root 405dc3e379 fix(ci): remove GitHub-specific marketplace actions incompatible with Gitea act runner
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 18s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 14s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 15s
- Remove actions/cache@v4 (uses GitHub's artifact API not available in Gitea)
- Remove actions/upload-artifact@v4 (same reason)
- Remove actions/download-artifact@v4 (same reason)
- Deploy job now builds from source instead of downloading artifacts
- Deploy container changed from alpine:3.20 to node:22-alpine for npm build
2026-06-22 00:57:53 -04:00
root 816f4cfaf0 fix: resolve all CI lint errors (9 errors → 0)
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 40s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 36s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
- Move SortTh outside AdminEmergencyContactIndexPage to fix
  react-hooks/static-components (5 errors)
- Fix ternary expressions used as statements in toggleSection
  and toggleDate (2 @typescript-eslint/no-unused-expressions errors)
- Remove stale eslint-disable-next-line react/no-danger comment
  referencing unregistered rule (1 error)
- Fix pre-existing any type in WiredParentScreens student.map
2026-06-22 00:38:47 -04:00
root 7282020444 chore: configure eslint rules and fix lint warnings
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 41s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
- Add eslint rules section downgrading strict react-hooks v7 rules to warn
- Remove unnecessary Boolean() wrappers across multiple pages
- Fix let→const for non-reassigned variable in AdminProgressPages
- Clean up stale eslint-disable-next-line comment and trailing whitespace
- Simplify setTimeout declaration in ManualPayPage
2026-06-22 00:17:18 -04:00
root 1992db65b8 fix(ci): use github.server_url template expression instead of missing GITEA_SERVER_URL env
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 40s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-21 21:42:45 -04:00
root 32875e54a8 ci: trigger pipeline with new default branch
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 2s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 2s
2026-06-21 15:10:35 -04:00
root 14eade0b7e fix(ci): install git via apk before clone in node:22-alpine image
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 2s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 2s
2026-06-21 15:08:32 -04:00
root 1e2345323f fix(ci): replace actions/checkout with git clone + GIT_SSL_NO_VERIFY to bypass self-signed cert
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 1s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 1s
2026-06-21 15:06:54 -04:00
root c3570481c8 fix(ci): merge duplicate push triggers in workflow
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 26s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 40s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-21 12:00:34 -04:00
root 09dbca4767 fix: add alpine container to deploy job for shared hosting compatibility
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 38s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 32s
Web Client CI/CD / Deploy to shared hosting (push) Has been skipped
2026-06-20 19:20:45 -04:00
root 2eaf590373 docs: add Gitea CI/CD setup checklist
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 32s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 37s
Web Client CI/CD / Deploy to production (push) Has been skipped
2026-06-20 19:18:40 -04:00
root 4b6232d2ba ci: add Gitea CI/CD workflow for React web client
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Failing after 37s
Web Client CI/CD / Build (tsc + Vite) (push) Failing after 36s
Web Client CI/CD / Deploy to production (push) Has been skipped
Pipeline includes lint (eslint), build (tsc + vite), and manual
deploy stages.
2026-06-20 19:16:38 -04:00
root cd29394211 fix cicd variables naming 2026-06-12 13:14:53 -04:00
root 1eb45f46a8 add test script to package json 2026-06-12 12:23:43 -04:00
root 665b26df1e remove pupeteer and ad ci gitlab 2026-06-12 01:10:24 -04:00
root 7365c14506 fix inventory 2026-06-11 11:06:41 -04:00
root e8d1b8c1c7 page fixes 2026-06-11 03:22:22 -04:00
root fe1c50878b add test create 3000 users 2026-06-08 01:53:25 -04:00
root b6513ab22f update the new school year model 2026-06-07 20:01:56 -04:00
root 8e79201a3c add school year model 2026-06-07 00:52:04 -04:00
root c4d7a06a17 fix financial and certificates 2026-06-05 01:51:12 -04:00
root 647b96cafc fix attendance 2026-06-04 20:49:24 -04:00
root dec023f03e fix teacher page 2026-06-04 19:26:30 -04:00
root 1b0d9a36ad fix api security issues and update pages issue 2026-06-04 16:41:25 -04:00
root ad7b13f4d8 fix exam grading 2026-06-04 13:25:41 -04:00
root 1af02dadd3 cleanup codeigniter code 2026-06-04 02:55:19 -04:00
root 4dde38004f fix 2026-05-25 15:54:56 -04:00
root 988d04c0a6 fix dev login 2026-05-18 00:10:14 -04:00
root 0d3cdd3857 add certificate 2026-05-18 00:07:40 -04:00
root dc7acf2536 add grading, attendnace management 2026-04-29 17:39:37 -04:00
root ea495f8382 update exam and attendance 2026-04-26 14:57:41 -04:00
root 0687d4677f fix class progress admin side 2026-04-25 21:56:17 -04:00
root 93b75b9b3f add badge logic 2026-04-25 01:23:20 -04:00
root 3e77fc92c7 fix teacher, parent and admin pages 2026-04-25 00:00:10 -04:00
root 7fe34dde0d add admin pages 2026-04-23 15:28:56 -04:00
root 94700d4f0f add parent pages 2026-04-23 02:24:05 -04:00
root 9191fd32f0 init project 2026-04-23 00:21:02 -04:00
366 changed files with 82545 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Gitea CI/CD — Setup Checklist
After pushing, the pipeline will run automatically. The following steps are required to unlock all features (especially deploy jobs).
## 1. Repository Secrets (Settings → Actions → Secrets)
| Secret Name | Description |
|---|---|
| `SSH_PASSWORD` | SSH password for the deployment server |
| `SSH_USER` | SSH username for the deployment server |
| `SSH_HOST` | IP or domain of the deployment server |
## 2. Repository Variables (Settings → Actions → Variables)
| Variable Name | Default | Description |
|---|---|---|
| `SSH_PORT` | `22` | SSH port for the deployment server |
| `SSH_TARGET_DIR` | — | Absolute path on the server where `dist/` contents should be placed |
| `PRODUCTION_URL` | — | Public URL of the production site |
| `VITE_API_URL` | — | (Optional) API base URL passed at build time |
## 3. Allow Gitea Actions
- Go to **Settings → Actions** in your Gitea repository
- Ensure **"Actions"** are enabled
- If using self-hosted runners, ensure a runner is registered and online
## 4. Optional: Configure Gitea Actions Runner
If no runner is registered yet on your Gitea instance:
```bash
# On the runner machine (Docker-based)
docker run -d \
--name gitea-runner \
-e GITEA_INSTANCE_URL=https://192.168.3.80 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<your-registration-token> \
-e GITEA_RUNNER_NAME=runner-1 \
-v /var/run/docker.sock:/var/run/docker.sock \
gitea/act_runner:latest
```
## Pipeline Stages
| Job | Requires Secrets/Vars | Runs on |
|---|---|---|
| `lint` | None | Push & PR |
| `build` | None | Push & PR |
| `deploy` | SSH secrets + vars | Manual on `develop` |
+132
View File
@@ -0,0 +1,132 @@
# Gitea CI/CD for alrahma_web_client (React + Vite + TypeScript)
#
# Triggers:
# - Push to any branch
# - Pull requests
# - Tags
#
# Stages: lint → build → deploy (manual)
name: Web Client CI/CD
on:
push:
branches: [main, master, develop, 'feature/**', 'fix/**']
tags: ['v*']
pull_request:
branches: [main, master, develop]
env:
CI: true
jobs:
# ──────────────────────────────────────────────
# LINT: ESLint with TypeScript
# ──────────────────────────────────────────────
lint:
name: Lint (ESLint + TypeScript)
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Run ESLint
run: npm run lint
# ──────────────────────────────────────────────
# BUILD: TypeScript + Vite
# ──────────────────────────────────────────────
build:
name: Build (tsc + Vite)
runs-on: ubuntu-latest
container:
image: node:22-alpine
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
# ──────────────────────────────────────────────
# DEPLOY (manual): to shared hosting via SSH
# ──────────────────────────────────────────────
deploy:
name: Deploy to shared hosting
runs-on: ubuntu-latest
container:
image: node:22-alpine
if: github.ref == 'refs/heads/develop'
needs: [lint, build]
environment:
name: production
url: ${{ vars.PRODUCTION_URL }}
steps:
- name: Checkout repository
env:
GIT_SSL_NO_VERIFY: "1"
run: |
apk add --no-cache git
SERVER="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
git clone --depth 1 \
"https://x-access-token:${TOKEN}@${SERVER#https://}/${REPO}.git" \
.
git fetch --depth 1 origin "${{ github.sha }}"
git checkout "${{ github.sha }}"
shell: sh
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Build project
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL || '' }}
- name: Install SSH and rsync
run: |
apk add --no-cache openssh-client sshpass rsync
- name: Deploy built files to shared hosting
run: |
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
-e "ssh -p ${{ vars.SSH_PORT || 22 }} -o StrictHostKeyChecking=no" \
--exclude='.htaccess' \
./dist/ \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ vars.SSH_TARGET_DIR }}/
+20
View File
@@ -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/
+63
View File
@@ -0,0 +1,63 @@
stages:
- build
- deploy
variables:
CACHE_KEY: "node-modules-${CI_COMMIT_REF_SLUG}"
BUILD_DIR: "dist"
cache:
key: $CACHE_KEY
paths:
- node_modules/
policy: pull-push
# Build stage
build:
stage: build
image: node:20-alpine
script:
- echo "Installing dependencies..."
- npm ci
- echo "Building project..."
- npm run build
- echo "Build completed!"
artifacts:
paths:
- $BUILD_DIR/
expire_in: 1 hour
only:
- develop
# Deploy to shared hosting via FTP
deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client sshpass rsync
script:
- echo "Starting deployment to $SSH_HOST on port $SSH_PORT..."
- |
if sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
-e "ssh -p $SSH_PORT -o StrictHostKeyChecking=no" \
--exclude='.htaccess' \
--exclude='.env' \
--exclude='.git' \
$BUILD_DIR/ $SSH_USER@$SSH_HOST:$SSH_TARGET_DIR/; then
echo "✅ Deployment successful!"
echo "🌐 Website: $PRODUCTION_URL"
else
echo "❌ Deployment failed!"
exit 1
fi
environment:
name: production
url: $PRODUCTION_URL
only:
- develop
when: manual
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
+32
View File
@@ -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_URL=
ENV VITE_API_URL=$VITE_API_URL
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
+21
View File
@@ -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"]
+32
View File
@@ -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_URL: ${VITE_API_URL:-}
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
ports:
- '8080:80'
profiles:
- prod
volumes:
client_node_modules:
+13
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
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,
},
rules: {
// ── New strict rules from react-hooks v7 — downgrade to warn ──
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
'react-hooks/immutability': 'warn',
'react-refresh/only-export-components': 'warn',
// ── Keep these as errors (real bugs / anti-patterns) ──
'react-hooks/static-components': 'error',
'@typescript-eslint/no-unused-expressions': 'error',
},
},
])
+32
View File
@@ -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>
+10
View File
@@ -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;
}
}
+5389
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
+3244
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.
+201
View File
@@ -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 19<\/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 childs 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, thats 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 students 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 students 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 childs 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 schools 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 students 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"
}
}
]
}
Binary file not shown.
Binary file not shown.
+32
View File
@@ -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
+42
View File
@@ -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";
+140
View File
@@ -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()
+85
View File
@@ -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()
+103
View File
@@ -0,0 +1,103 @@
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;
}
.school-year-selector {
max-width: 100%;
}
.school-year-selector .form-select {
min-width: 11rem;
}
.school-year-selector--compact .form-label {
color: inherit;
}
.school-year-selector--compact .form-select {
width: auto;
min-width: 10rem;
}
.navbar-dark .school-year-selector,
.custom-navbar .school-year-selector {
color: inherit;
}
.navbar-dark .school-year-selector .form-label,
.custom-navbar .school-year-selector .form-label {
color: inherit;
opacity: 0.85;
}
+1573
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../../alrahma_sunday_school_api"
},
{
"path": "../.."
}
],
"settings": {}
}
+146
View File
@@ -0,0 +1,146 @@
import { apiFetch } from './http'
const BASE = '/api/v1/attendance/management'
type ApiEnvelope<T> = { status?: boolean; message?: string; data?: T }
function unwrap<T>(body: ApiEnvelope<T> | T): T {
if (body && typeof body === 'object' && 'data' in body) return (body as ApiEnvelope<T>).data as T
return body as T
}
export type AttendanceManagementSummary = {
present: number
absent: number
late: number
early_dismissal: number
not_reported: number
follow_up_required: number
badge_exceptions: number
late_slip_reprints: number
}
export type AttendanceManagementEvent = {
id: number
person_type: string
person_id: number | null
person_name: string | null
role_grade: string | null
badge_id: string | null
event_date: string
attendance_status: string
report_status: string
entry_time: string | null
exit_time: string | null
official_entry_time: string | null
official_exit_time: string | null
entry_method: string | null
exit_method: string | null
manual_reason: string | null
reason: string | null
scan_location: string | null
exit_location: string | null
absence_count: number
late_count: number
early_dismissal_count: number
badge_exception_count: number
combination_code: string | null
risk_level: string
follow_up_required: boolean | number
follow_up_completed: boolean | number
action_needed: string | null
final_decision: string | null
notes: string | null
}
export type AttendanceManagementDashboard = {
date: string
summary: AttendanceManagementSummary
filters: {
attendance_status: string[]
report_status: string[]
risk_level: string[]
person_type: string[]
}
rows: AttendanceManagementEvent[]
}
export type ManualEntryPayload = {
person_type?: string
person_id?: number
person_name?: string
role_grade?: string
badge_id?: string
entry_time?: string
manual_reason?: string
report_status?: string
reason?: string
notes?: string
}
export type ScanPayload = {
badge_scan?: string
badge_id?: string
scan_type?: 'entry' | 'exit'
scan_time?: string
location?: string
report_status?: string
authorized?: boolean
reason?: string
}
export async function fetchAttendanceManagementDashboard(params: Record<string, string> = {}) {
const qs = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v.trim()) qs.set(k, v.trim())
})
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return unwrap<AttendanceManagementDashboard>(await apiFetch(`${BASE}/dashboard${suffix}`))
}
export async function fetchBadgeScanningList(params: Record<string, string> = {}) {
return fetchAttendanceManagementDashboard({
...params,
entry_method: 'badge_scan',
})
}
export async function recordManualAttendanceEntry(payload: ManualEntryPayload) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/manual-entry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function recordAttendanceBadgeScan(payload: ScanPayload) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function completeAttendanceFollowUp(id: number, payload: { report_status?: string; final_decision?: string; notes?: string }) {
return unwrap<{ event: AttendanceManagementEvent }>(
await apiFetch(`${BASE}/${id}/follow-up`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
export async function reprintAttendanceLateSlip(id: number, payload: { reason?: string; slip_number?: string } = {}) {
return unwrap<{ reprint: Record<string, unknown> }>(
await apiFetch(`${BASE}/${id}/late-slip-reprint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
+39
View File
@@ -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
}
+156
View File
@@ -0,0 +1,156 @@
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, 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}`)
applyStoredSchoolYearHeaders(headers)
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()
}
+148
View File
@@ -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),
})
}
+307
View File
@@ -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${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}/below-sixty/meeting${q(searchParams)}`)
}
export async function postScheduleMeeting(body: Record<string, unknown>): Promise<unknown> {
return apiFetch(`${BASE}/below-sixty/meeting`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
+112
View File
@@ -0,0 +1,112 @@
import { apiUrl } from '../lib/apiOrigin'
import { selectedSchoolYearHeaders, withSelectedSchoolYearUrl } from '../lib/schoolYearSelection'
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 function applyStoredSchoolYearHeaders(headers: Headers): Headers {
for (const [key, value] of Object.entries(selectedSchoolYearHeaders())) {
if (!headers.has(key)) headers.set(key, value)
}
return headers
}
export function withStoredSchoolYearUrl(url: string): string {
return withSelectedSchoolYearUrl(url)
}
export class ApiHttpError extends Error {
readonly status: number
readonly body: unknown
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}`)
}
if (attachAuth && token) {
applyStoredSchoolYearHeaders(headers)
}
const method = String(init.method ?? 'GET').toUpperCase()
const url = apiUrl(method === 'GET' ? withStoredSchoolYearUrl(path) : path)
let res: Response
try {
res = await fetch(url, {
...init,
headers,
})
} 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_URL is set.'
: ''
const reason = e instanceof Error ? e.message : 'Failed to fetch'
throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null)
}
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
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
}
+224
View File
@@ -0,0 +1,224 @@
/**
* 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}` : ''
}
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type InventoryKind = 'book' | 'classroom' | 'kitchen' | 'office'
/** Index list: expect `{ items, categories, userNames }` (names optional). */
export async function fetchInventoryIndex(
kind: InventoryKind,
searchParams: URLSearchParams,
): Promise<unknown> {
const params = new URLSearchParams(searchParams)
params.set('type', kind)
return unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`))
}
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
return apiFetch(`${BASE}/categories`, {
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 unwrapData(await apiFetch(`${BASE}/items/${itemId}`))
}
/** Create form payload (categories, defaults). */
export async function fetchInventoryCreateForm(
kind: InventoryKind,
searchParams: URLSearchParams,
): Promise<unknown> {
const params = new URLSearchParams(searchParams)
params.set('type', kind)
return unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`))
}
export async function postInventoryItem(body: Record<string, unknown>): Promise<unknown> {
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: 'PATCH',
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}/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 unwrapData(await apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`))
}
export async function fetchMovements(searchParams: URLSearchParams): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`))
}
export async function fetchMovementForm(
movementId: string | number | undefined,
searchParams: URLSearchParams,
): Promise<unknown> {
if (movementId === undefined || movementId === '') {
return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`))
}
return unwrapData(await 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: 'PATCH',
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 unwrapData(await apiFetch(`${BASE}/low-stock`))
}
/** Fetch reorder suggestions for low-stock items. */
export async function fetchReorderSuggestions(): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/reorder-suggestions`))
}
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
export async function fetchStockStatus(
searchParams: URLSearchParams,
): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/stock-status${q(searchParams)}`))
}
/** Fetch inventory dashboard summary counts. */
export async function fetchInventoryDashboard(
searchParams: URLSearchParams,
): Promise<unknown> {
return unwrapData(await apiFetch(`${BASE}/dashboard${q(searchParams)}`))
}
/** Void a movement (append-only correction with reverse entry). */
export async function voidMovement(
movementId: string | number,
reason: string,
): Promise<unknown> {
return apiFetch(`${BASE}/movements/${movementId}/void`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
})
}
/** Create a correction movement linked to an existing movement. */
export async function correctMovement(
movementId: string | number,
qtyChange: number,
reason: string,
): Promise<unknown> {
return apiFetch(`${BASE}/movements/${movementId}/correct`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qty_change: qtyChange, reason }),
})
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Administrator invoice management & PDF (parity with CI `invoice_payment/*`).
*/
import { apiUrl } from '../lib/apiOrigin'
import { apiFetch, applyStoredSchoolYearHeaders, 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 headers = new Headers({ Accept: 'application/pdf' })
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`${BASE}/${invoiceId}/pdf`), { headers })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `PDF HTTP ${res.status}`)
}
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)
}
+70
View File
@@ -0,0 +1,70 @@
import { apiFetch } from './http'
export type ParentProfileSummary = {
id: number
firstname?: string | null
lastname?: string | null
email?: string | null
cellphone?: string | null
is_active?: boolean
families_count?: number
selected_year_students_count?: number
balance?: number
positive_unpaid_balance?: number
credit_balance?: number
primary_family?: { id?: number; household_name?: string | null } | null
}
export type ParentProfileDetail = {
school_year: string
read_only: boolean
parent: Record<string, unknown> | null
families: Record<string, unknown>[]
students: Record<string, unknown>[]
emergency_contacts: Record<string, unknown>[]
invoices: Record<string, unknown>[]
payments: Record<string, unknown>[]
finance_summary: Record<string, unknown>
}
type Envelope<T> = { data?: T }
function unwrap<T>(response: T | Envelope<T>): T {
if (response && typeof response === 'object' && 'data' in response) {
return (response as Envelope<T>).data as T
}
return response as T
}
export async function fetchParentProfiles(params: {
schoolYear: string
q?: string
page?: number
perPage?: number
}): Promise<{
school_year: string
read_only: boolean
parents: ParentProfileSummary[]
pagination: { page: number; per_page: number; total: number }
}> {
const query = new URLSearchParams()
query.set('school_year', params.schoolYear)
if (params.q?.trim()) query.set('q', params.q.trim())
if (params.page) query.set('page', String(params.page))
if (params.perPage) query.set('per_page', String(params.perPage))
return unwrap(await apiFetch(`/api/v1/parent-profiles?${query.toString()}`))
}
export async function fetchParentProfile(parentId: number, schoolYear: string): Promise<ParentProfileDetail> {
const query = new URLSearchParams({ school_year: schoolYear })
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`))
}
export async function updateParentProfile(parentId: number, schoolYear: string, payload: Record<string, unknown>): Promise<ParentProfileDetail> {
const query = new URLSearchParams({ school_year: schoolYear })
return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, school_year: schoolYear }),
}))
}
+49
View File
@@ -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),
})
}
+442
View File
@@ -0,0 +1,442 @@
/**
* 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, applyStoredSchoolYearHeaders, 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}`)
applyStoredSchoolYearHeaders(headers)
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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(apiPath), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const raw = await res.blob()
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}`)
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Teacher / admin print copy requests (legacy CI: print-requests/*).
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } from './http'
export type PrintRequestRow = Record<string, unknown>
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type TeacherPrintRequestsContext = {
sundays?: string[]
times?: string[]
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}`)
applyStoredSchoolYearHeaders(headers)
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 unwrapData(await apiFetch('/api/v1/print-requests/admin'))
}
export async function adminUpdatePrintRequestStatus(
requestId: number | string,
payload: { status: string; admin_id?: number | string },
): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}/status`, {
method: 'PATCH',
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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(printRequestFileUrl(requestId)), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener')
}
+359
View File
@@ -0,0 +1,359 @@
/**
* Stickers, badges, report cards (legacy CI printables_reports/*).
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, 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}` : ''
const body = await apiFetch<{
ok?: boolean
data?: { classes?: ClassOption[]; students?: StudentOption[] }
classes?: ClassOption[]
students?: StudentOption[]
}>(`/api/v1/reports/stickers/form-data${suffix}`)
return body.data ?? body
}
export async function fetchStudentsByClass(classId: number | string): Promise<StudentOption[]> {
const qs = new URLSearchParams({ class_section_id: String(classId) })
const body = await apiFetch<{
ok?: boolean
data?: { students?: StudentOption[] }
students?: StudentOption[]
}>(`/api/v1/reports/stickers/students?${qs}`)
return body.data?.students ?? body.students ?? []
}
export async function postStickerGenerate(formData: FormData): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl('/api/v1/reports/stickers/print'), {
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}`)
applyStoredSchoolYearHeaders(headers)
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/reports/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}` : ''
const body = await apiFetch<ReportCardMeta & { data?: ReportCardMeta }>(`/api/v1/administrator/printables/report-card/meta${suffix}`)
return body.data ?? body
}
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 ?? '')
const body = await apiFetch<{ data?: { viewed_at?: string; signed_at?: string; signed_name?: string }; viewed_at?: string; signed_at?: string; signed_name?: string }>(`/api/v1/administrator/printables/report-card/ack?${qs}`)
return body.data ?? body
}
export async function fetchReportCardCompleteness(params: {
class_section_id: number | string
school_year?: string
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 ?? '')
const body = await apiFetch<{
data?: { students?: Array<Record<string, unknown>>; summary?: Record<string, unknown>; used_fallback?: boolean }
students?: Array<Record<string, unknown>>
summary?: Record<string, unknown>
used_fallback?: boolean
}>(`/api/v1/administrator/printables/report-card/completeness?${qs}`)
return body.data ?? body
}
function reportCardStudentPath(
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 `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
}
function reportCardClassPath(
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 `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
}
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
export function reportCardStudentUrl(
studentId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
return apiUrl(reportCardStudentPath(studentId, opts))
}
export function reportCardClassUrl(
classSectionId: number | string,
opts: { school_year: string; semester?: string; download?: boolean; report_date?: string },
): string {
return apiUrl(reportCardClassPath(classSectionId, opts))
}
/** HTML document for iframe preview (Bearer auth; unlike raw iframe src). */
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}`)
applyStoredSchoolYearHeaders(headers)
const path = `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
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}`)
applyStoredSchoolYearHeaders(headers)
const path = `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
return res.text()
}
async function fetchReportCardPdf(path: string): Promise<Blob> {
const headers = new Headers({ Accept: 'application/pdf,*/*' })
const token = getStoredToken()
if (!token) {
throw new ApiHttpError('Your session has expired. Please sign in again.', 401, null)
}
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) {
const errBody: unknown = await res.json().catch(() => null)
const msg =
typeof errBody === 'object' && errBody !== null && 'message' in errBody
? String((errBody as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody)
}
const raw = await res.blob()
return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' })
}
function openBlob(blob: Blob): void {
const objectUrl = URL.createObjectURL(blob)
window.open(objectUrl, '_blank', 'noopener')
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
}
/** Opens PDF download in new tab (authenticated GET). */
export async function openReportCardStudentDownload(
studentId: number | string,
opts: { school_year: string; semester?: string; report_date?: string },
): Promise<void> {
openBlob(
await fetchReportCardPdf(
reportCardStudentPath(studentId, {
...opts,
download: true,
}),
),
)
}
export async function openReportCardClassDownload(
classSectionId: number | string,
opts: { school_year: string; semester?: string; report_date?: string },
): Promise<void> {
openBlob(
await fetchReportCardPdf(
reportCardClassPath(classSectionId, {
...opts,
download: true,
}),
),
)
}
+47
View File
@@ -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)
}
+127
View File
@@ -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
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Admin reimbursements (legacy CI reimbursements/*).
* Backend is expected under `/api/v1/administrator/reimbursements`.
*/
import { apiUrl } from '../lib/apiOrigin'
import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, 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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
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}/batches/export?${qs}`)
const headers = new Headers({ Accept: 'text/csv,*/*' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(url, { headers })
if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null)
const blob = await res.blob()
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}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(`${BASE}/batches/email`), { method: 'POST', headers, body: formData })
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
const msg =
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}/batches`, fd)
}
async function postFormExpectJson(path: string, form: FormData): Promise<{ success?: boolean; error?: string; csrf_hash?: string; reimbursement_id?: number } & Record<string, unknown>> {
const headers = new Headers({ Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' })
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
applyStoredSchoolYearHeaders(headers)
const res = await fetch(apiUrl(path), { method: 'POST', headers, body: form })
const body: unknown = await res.json().catch(() => ({}))
const data = body as Record<string, unknown>
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}/batches/assign`, form)
}
export function postBatchLock(form: FormData) {
return postFormExpectJson(`${BASE}/batches/lock`, form)
}
export function postAdminCheckUpload(form: FormData) {
return postFormExpectJson(`${BASE}/batches/admin-files`, 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}`)
applyStoredSchoolYearHeaders(headers)
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)
}
+131
View File
@@ -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}`)
}
+17
View File
@@ -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')
}
+343
View File
@@ -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)
}
+459
View File
@@ -0,0 +1,459 @@
import { apiFetch } from './http'
export type SchoolYearStatus = 'draft' | 'active' | 'closed' | 'archived' | string
export type SchoolYearRecord = {
id: number
name: string
start_date: string
end_date: string
status: SchoolYearStatus
is_current: boolean
isCurrent?: boolean
closed_at?: string | null
closed_by?: number | null
}
export type SchoolYearSummary = {
school_year: SchoolYearRecord
totals: {
students_considered: number
students_blocked: number
parents_with_balances: number
parents_with_positive_balance?: number
net_balance_to_transfer: number
net_balance_impact?: number
total_positive_unpaid_balance?: number
total_credit_balance?: number
}
}
export type SchoolYearPromotionRow = {
student_id: number
student_name: string
parent_id?: number | null
current_grade?: string | null
current_class?: string | null
promotion_action: string
target_grade?: string | null
target_class?: string | null
target_class_section_id?: number | null
warnings?: string[]
decision?: string | null
score?: number | null
}
export type SchoolYearPromotionPreview = {
rows: SchoolYearPromotionRow[]
summary: {
total_students: number
promote: number
repeat: number
graduate: number
transfer: number
withdraw: number
hold: number
}
}
export type SchoolYearParentBalanceRow = {
parent_id: number
parent_name?: string | null
student_names?: string[]
old_unpaid_balance: number
positive_unpaid_balance?: number
credit_balance: number
net_balance?: number
amount_to_transfer: number
positive_invoice_ids?: number[]
credit_invoice_ids?: number[]
source_invoice_ids?: number[]
existing_transfer_conflict?: boolean
}
export type SchoolYearParentBalancePreview = {
from_school_year?: string
to_school_year?: string
rows: SchoolYearParentBalanceRow[]
summary: {
parents_with_non_zero_balance: number
parents_with_positive_balance?: number
parents_with_credit_balance: number
total_old_unpaid_balance: number
total_positive_unpaid_balance?: number
total_credit_balance: number
net_balance_to_transfer: number
net_balance_impact?: number
existing_transfer_conflicts: number
}
}
export type SchoolYearCloseValidation = {
can_close: boolean
errors: string[]
warnings: string[]
promotion_summary: SchoolYearPromotionPreview['summary']
financial_summary: SchoolYearParentBalancePreview['summary']
}
export type SchoolYearClosePreview = {
school_year: SchoolYearRecord
validation: SchoolYearCloseValidation
promotion_preview: SchoolYearPromotionPreview
parent_balances: SchoolYearParentBalancePreview
}
export type SchoolYearCloseResult = {
closed_school_year: SchoolYearRecord
new_school_year: SchoolYearRecord
promotion_counts?: Record<string, number>
balance_counts?: Record<string, number>
}
export type SchoolYearClosingReport = {
old_school_year?: SchoolYearRecord | null
new_school_year?: SchoolYearRecord | null
closed_by?: string | number | null
closed_at?: string | null
audit_reference?: string | null
totals?: {
students_promoted?: number
students_repeated?: number
students_graduated?: number
students_transferred?: number
students_withdrawn?: number
parents_with_unpaid_balances?: number
total_unpaid_balance_transferred?: number
parents_with_credit_balances?: number
total_credit_carried_or_reported?: number
total_credit_transferred?: number
net_balance_impact?: number
}
warnings?: string[]
}
export type SchoolYearBalanceTransferReportRow = {
parent_id: number
parent_name?: string | null
old_school_year?: string | null
new_school_year?: string | null
source_invoice_ids?: Array<number | string>
old_unpaid_amount?: number
new_old_balance_invoice_id?: number | string | null
transfer_status?: string | null
transfer_date?: string | null
created_by?: string | number | null
}
export type SchoolYearBalanceTransferReport = {
school_year?: SchoolYearRecord | null
rows: SchoolYearBalanceTransferReportRow[]
summary?: {
parent_count?: number
total_transferred?: number
total_credit?: number
}
}
export type ParentStatementLine = {
label: string
amount: number
source_school_year?: string | null
invoice_id?: number | string | null
}
export type ParentStatement = {
parent_id?: number | null
parent_name?: string | null
school_year?: SchoolYearRecord | string | null
lines: ParentStatementLine[]
opening_balance?: number
current_balance?: number
}
export type SaveSchoolYearPayload = {
name: string
start_date: string
end_date: string
}
export type CloseSchoolYearPayload = {
new_school_year: SaveSchoolYearPayload
promotion_rules?: {
default_action?: string
exceptions?: Array<{
student_id: number
action: string
target_grade_id?: number | null
target_class_section_id?: number | null
}>
}
transfer_unpaid_balances?: boolean
confirmation?: string
}
type ApiListResponse = {
ok?: boolean
data?: SchoolYearRecord[]
}
type ApiRecordResponse = {
ok?: boolean
data?: SchoolYearRecord
}
type ApiSummaryResponse = {
ok?: boolean
data?: SchoolYearSummary
}
type ApiValidationResponse = {
ok?: boolean
validation?: SchoolYearCloseValidation
}
type ApiPreviewResponse = {
ok?: boolean
data?: SchoolYearClosePreview
}
type ApiParentBalancesResponse = {
ok?: boolean
data?: SchoolYearParentBalancePreview
}
type ApiPromotionPreviewResponse = {
ok?: boolean
data?: SchoolYearPromotionPreview
}
type ApiCloseResponse = {
ok?: boolean
data?: SchoolYearCloseResult
}
type ApiClosingReportResponse = {
ok?: boolean
data?: SchoolYearClosingReport
}
type ApiBalanceTransferReportResponse = {
ok?: boolean
data?: SchoolYearBalanceTransferReport
}
type ApiParentStatementResponse = {
ok?: boolean
data?: ParentStatement
}
export async function fetchSchoolYears(): Promise<SchoolYearRecord[]> {
const response = await apiFetch<ApiListResponse>('/api/v1/school-years')
return response.data ?? []
}
export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>('/api/v1/school-years', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.data) {
throw new Error('School year creation returned no record.')
}
return response.data
}
export async function updateSchoolYear(
schoolYear: string,
payload: SaveSchoolYearPayload,
): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(
`/api/v1/school-years/selected${selectedYearQuery(schoolYear)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.data) {
throw new Error('School year update returned no record.')
}
return response.data
}
function selectedYearQuery(schoolYear: string, extras?: Record<string, string | undefined>): string {
const query = new URLSearchParams()
query.set('school_year', schoolYear)
for (const [key, value] of Object.entries(extras ?? {})) {
if (value) query.set(key, value)
}
return `?${query.toString()}`
}
function selectedYearBody(payload: object, schoolYear: string): string {
return JSON.stringify({ ...payload, school_year: schoolYear })
}
export async function fetchSchoolYearSummary(schoolYear: string): Promise<SchoolYearSummary> {
const response = await apiFetch<ApiSummaryResponse>(
`/api/v1/school-years/summary${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year summary returned no data.')
}
return response.data
}
export async function validateSchoolYearClose(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseValidation> {
const response = await apiFetch<ApiValidationResponse>(
`/api/v1/school-years/validate-close${selectedYearQuery(schoolYear)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.validation) {
throw new Error('School year validation returned no payload.')
}
return response.validation
}
export async function previewSchoolYearClose(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearClosePreview> {
const response = await apiFetch<ApiPreviewResponse>(
`/api/v1/school-years/preview-close${selectedYearQuery(schoolYear)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.data) {
throw new Error('School year close preview returned no payload.')
}
return response.data
}
export async function closeSchoolYear(
schoolYear: string,
payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseResult> {
const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/close${selectedYearQuery(schoolYear)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
})
if (!response.data) {
throw new Error('School year close returned no payload.')
}
return response.data
}
export async function reopenSchoolYear(schoolYear: string): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/reopen${selectedYearQuery(schoolYear)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ school_year: schoolYear }),
})
if (!response.data) {
throw new Error('School year reopen returned no payload.')
}
return response.data
}
export async function fetchSchoolYearParentBalances(
schoolYear: string,
toSchoolYear?: string,
): Promise<SchoolYearParentBalancePreview> {
const response = await apiFetch<ApiParentBalancesResponse>(
`/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
)
if (!response.data) {
throw new Error('Parent balances preview returned no payload.')
}
return response.data
}
export async function fetchSchoolYearPromotionPreview(
schoolYear: string,
toSchoolYear?: string,
): Promise<SchoolYearPromotionPreview> {
const response = await apiFetch<ApiPromotionPreviewResponse>(
`/api/v1/school-years/promotion-preview${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
)
if (!response.data) {
throw new Error('Promotion preview returned no payload.')
}
return response.data
}
export async function fetchSchoolYearClosingReport(
schoolYear: string,
): Promise<SchoolYearClosingReport> {
const response = await apiFetch<ApiClosingReportResponse>(
`/api/v1/school-years/reports/closing${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year closing report returned no payload.')
}
return response.data
}
export async function fetchSchoolYearBalanceTransferReport(
schoolYear: string,
): Promise<SchoolYearBalanceTransferReport> {
const response = await apiFetch<ApiBalanceTransferReportResponse>(
`/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear)}`,
)
if (!response.data) {
throw new Error('School year balance transfer report returned no payload.')
}
return response.data
}
export async function fetchParentStatement(params: {
schoolYear?: string | null
parentId?: number | null
}): Promise<ParentStatement> {
const query = new URLSearchParams()
if (params.schoolYear) query.set('school_year', params.schoolYear)
if (params.parentId != null) query.set('parent_id', String(params.parentId))
const response = await apiFetch<ApiParentStatementResponse>(
`/api/v1/parents/statements${query.toString() ? `?${query}` : ''}`,
)
if (!response.data) {
throw new Error('Parent statement returned no payload.')
}
return response.data
}
+24
View File
@@ -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}`)
}
+2593
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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 ?? [] }
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Staff directory.
*/
import { apiFetch } from './http'
const BASE = '/api/v1/staff'
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type StaffListRow = {
id: number
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
status?: string | null
}
export type StaffIndexResponse = {
staff?: StaffListRow[]
issues_count?: number
school_year?: string | null
semester?: string | null
schoolYears?: string[]
meta?: unknown
}
export type RoleOption = { id: number; name: string; label?: string }
export type StaffFormOptionsResponse = {
roles?: RoleOption[]
statuses?: Array<{ value: string; label: string }>
school_year?: string | null
is_read_only_year?: boolean
}
export type StaffMemberResponse = {
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
role?: string
status?: string
search?: string
page?: number
per_page?: number
}): Promise<StaffIndexResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
if (params?.role) qs.set('role', params.role)
if (params?.status) qs.set('status', params.status)
if (params?.search) qs.set('search', params.search)
if (params?.page) qs.set('page', String(params.page))
if (params?.per_page) qs.set('per_page', String(params.per_page))
const suffix = qs.toString() ? `?${qs}` : ''
return unwrapData(await apiFetch(`${BASE}${suffix}`))
}
export async function fetchStaffFormOptions(params?: { school_year?: string }): Promise<StaffFormOptionsResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
return unwrapData(await apiFetch(`${BASE}/form-options${qs.toString() ? `?${qs}` : ''}`))
}
export async function fetchStaffMember(id: number, params?: { school_year?: string }): Promise<StaffMemberResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
return unwrapData(await apiFetch(`${BASE}/${id}${qs.toString() ? `?${qs}` : ''}`))
}
export async function createStaff(payload: {
firstname: string
lastname: string
email: string
phone?: string
password: string
role_id: number
status?: string
school_year: string
}): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function updateStaff(
id: number,
payload: { firstname: string; lastname: string; email: string; phone?: string; role_id?: number; status?: string; school_year: string },
): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Teacher portal API (`Views/teacher/*` parity).
* Laravel should expose matching routes under `/api/v1/teacher/...`.
*/
import { apiFetch, applyStoredSchoolYearHeaders, 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}`)
applyStoredSchoolYearHeaders(headers)
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
}
+144
View File
@@ -0,0 +1,144 @@
import { apiFetch } from './http'
type TrophyApiEnvelope<T> = {
ok?: boolean
data?: T
}
export type TrophyStudent = {
student_id?: number
class_section_id?: number
name?: string
firstname?: string | null
lastname?: string | null
school_id?: string | null
gender?: string | null
fall_score?: number | null
spring_score?: number | null
year_score?: number | null
projected_trophy?: boolean
predicted?: boolean
actual?: boolean
status?: string
}
export type TrophyProjectionClassResult = {
section_id: number
section_name: string
students: TrophyStudent[]
threshold: number | null
trophy_count: number
student_count: number
scored_count: number
method: string
boys: number
girls: number
trophy_boys: number
trophy_girls: number
pct_boys: number
pct_girls: number
pct_trophy_boys: number
pct_trophy_girls: number
pct_trophy_total: number
}
export type TrophyProjectionPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyProjectionClassResult[]
summary: Record<string, number>
}
export type TrophyWinnersClassResult = {
section_id: number
section_name: string
threshold: number | null
winners: TrophyStudent[]
student_count: number
boys: number
girls: number
trophy_boys: number
trophy_girls: number
pct_boys: number
pct_girls: number
pct_trophy_boys: number
pct_trophy_girls: number
pct_trophy_total: number
}
export type TrophyWinnersPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyWinnersClassResult[]
summary: Record<string, number>
}
export type TrophyFinalClassResult = {
section_id: number
section_name: string
students: TrophyStudent[]
student_count: number
fall_threshold: number | null
year_threshold: number | null
predicted_count: number
actual_count: number
confirmed: number
surprises: number
missed: number
accuracy: number
}
export type TrophyGenderSummary = {
total: number
boys: number
girls: number
other: number
pct_boys: number
pct_girls: number
pct_other: number
}
export type TrophyFinalPayload = {
selected_year: string
selected_percentile: number
years: string[]
class_results: TrophyFinalClassResult[]
summary: Record<string, number>
winner_gender_summary: TrophyGenderSummary
all_gender_summary: TrophyGenderSummary
pass_gender_summary: TrophyGenderSummary
winner_stickers: Array<{ name: string; section: string }>
}
type TrophyFilters = {
school_year?: string
percentile?: string | number
}
function withQuery(path: string, params?: TrophyFilters) {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', String(params.school_year))
if (params?.percentile !== undefined && String(params.percentile).trim() !== '') {
qs.set('percentile', String(params.percentile))
}
return qs.size > 0 ? `${path}?${qs.toString()}` : path
}
async function fetchTrophyPayload<T>(path: string, params?: TrophyFilters): Promise<T> {
const res = await apiFetch<TrophyApiEnvelope<T>>(withQuery(path, params))
return (res.data ?? {}) as T
}
export function fetchTrophyProjection(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyProjectionPayload>('/api/v1/administrator/trophy', params)
}
export function fetchTrophyWinners(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyWinnersPayload>('/api/v1/administrator/trophy/winners', params)
}
export function fetchTrophyFinal(params?: TrophyFilters) {
return fetchTrophyPayload<TrophyFinalPayload>('/api/v1/administrator/trophy/final', params)
}
+1727
View File
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
/**
* WhatsApp admin tools (legacy CI `whatsapp/*`).
* Expected Laravel routes under `/api/v1/whatsapp`.
*/
import { apiFetch } from './http'
const BASE = '/api/v1/whatsapp'
function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) {
return (body as { data?: T }).data as T
}
return body as T
}
export type WhatsappSectionRow = {
class_section_id?: number
id?: number
class_section_name?: string
name?: string
school_year?: string
semester?: string
}
export type WhatsappLinkRow = {
id?: number
class_section_id?: number
class_section_name?: string
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)
if (!qs.has('per_page')) qs.set('per_page', '200')
const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<WhatsappManageLinksResponse & {
data?: WhatsappManageLinksResponse & { links?: WhatsappLinkRow[] }
links?: WhatsappLinkRow[]
class_sections?: ParentContactsByClassSection[]
}>(`${BASE}/links${suffix}`)
const data = unwrapData(body) as WhatsappManageLinksResponse & {
links?: WhatsappLinkRow[]
class_sections?: ParentContactsByClassSection[]
}
if (!data.sections && data.class_sections) data.sections = data.class_sections
if (!data.linksBySection && Array.isArray(data.links)) {
data.linksBySection = Object.fromEntries(
data.links
.filter((link) => link.class_section_id != null)
.map((link) => [String(link.class_section_id), link]),
)
}
return data
}
export async function saveWhatsappLink(payload: {
class_section_id: number
class_section_name?: string
invite_link: string
active?: boolean
}): Promise<{ ok?: boolean; message?: string }> {
return unwrapData(await 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 unwrapData(await 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}` : ''
const body = await apiFetch<WhatsappParentContactsResponse & { data?: WhatsappParentContactsResponse }>(`${BASE}/parent-contacts${suffix}`)
return unwrapData(body)
}
export async function fetchWhatsappParentContactsByClass(params?: {
school_year?: string
semester?: string
}): Promise<WhatsappParentContactsByClassResponse> {
const qs = new URLSearchParams()
if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch<{
data?: { class_sections?: ParentContactsByClassSection[]; classSections?: ParentContactsByClassSection[] }
class_sections?: ParentContactsByClassSection[]
classSections?: ParentContactsByClassSection[]
}>(`${BASE}/parent-contacts-by-class${suffix}`)
const data = unwrapData(body)
return { classSections: data.classSections ?? data.class_sections ?? [] }
}
export async function updateWhatsappMembership(payload: {
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 }> {
const body = await apiFetch<{ status?: boolean; message?: string; data?: unknown }>(`${BASE}/membership`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { status: body.status ? 'ok' : undefined, message: body.message }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+199
View File
@@ -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
}
+18
View File
@@ -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 />
}
File diff suppressed because it is too large Load Diff
+265
View File
@@ -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,23 @@
import { useSchoolYear } from '../../context/SchoolYearContext'
export function ReadOnlyBanner({ className = '' }: { className?: string }) {
const { selectedSchoolYear, isReadOnlyYear } = useSchoolYear()
if (!selectedSchoolYear || !isReadOnlyYear) return null
return (
<div className={`alert alert-warning d-flex align-items-start gap-2 mb-3 ${className}`.trim()} role="status">
<i className="bi bi-lock-fill mt-1" aria-hidden />
<div>
<div className="fw-semibold">
Viewing archived school year: {selectedSchoolYear.name}
</div>
<div className="small">
Read-only mode. Add, edit, delete, import, attendance, grading, invoice, payment,
promotion, assignment, upload, and year-bound communication actions should remain disabled.
The API still has to reject forbidden writes, because buttons are not security.
</div>
</div>
</div>
)
}
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function ReadOnlyGuard({
children,
fallback,
}: {
children: ReactNode
fallback?: ReactNode
}) {
const { isReadOnlyYear, selectedSchoolYear } = useSchoolYear()
if (!isReadOnlyYear) return <>{children}</>
return (
<div className="border rounded p-3 bg-light text-muted">
{fallback ?? `This action is disabled because ${selectedSchoolYear?.name ?? 'the selected year'} is read-only.`}
</div>
)
}
@@ -0,0 +1,52 @@
import { useId } from 'react'
import { useSchoolYear } from '../../context/SchoolYearContext'
import { isCurrentSchoolYear } from '../../lib/schoolYearSelection'
import { SchoolYearStatusBadge } from './SchoolYearStatusBadge'
export function SchoolYearSelector({ compact = false }: { compact?: boolean }) {
const {
schoolYears,
selectedSchoolYear,
isLoadingSchoolYears,
schoolYearError,
setSelectedSchoolYearName,
} = useSchoolYear()
const selectId = useId()
if (schoolYears.length === 0 && !isLoadingSchoolYears) {
return schoolYearError ? (
<span className="badge text-bg-warning" title={schoolYearError}>
School years unavailable
</span>
) : null
}
return (
<div className={`school-year-selector d-flex align-items-center gap-2 ${compact ? 'school-year-selector--compact' : ''}`}>
<label className="form-label mb-0 small text-nowrap" htmlFor={selectId}>
School Year
</label>
<select
id={selectId}
className="form-select form-select-sm"
value={selectedSchoolYear?.name ?? ''}
disabled={isLoadingSchoolYears || schoolYears.length === 0}
onChange={(event) => {
setSelectedSchoolYearName(event.target.value || null)
}}
>
{isLoadingSchoolYears && schoolYears.length === 0 ? (
<option value="">Loading</option>
) : null}
{schoolYears.map((year) => (
<option key={year.name} value={year.name}>
{year.name}
{isCurrentSchoolYear(year) ? ' Current' : ''}
{year.status && !isCurrentSchoolYear(year) ? ` ${year.status}` : ''}
</option>
))}
</select>
{selectedSchoolYear ? <SchoolYearStatusBadge status={selectedSchoolYear.status} /> : null}
</div>
)
}

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