init project
This commit is contained in:
+20
@@ -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/
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# If npm/yarn fail with "unable to get local issuer certificate" during install, build with:
|
||||||
|
# docker compose build --build-arg NPM_STRICT_SSL=false client-prod
|
||||||
|
# (Only use on trusted networks; prefer mounting your org CA via NODE_EXTRA_CA_CERTS instead.)
|
||||||
|
ARG NPM_STRICT_SSL=true
|
||||||
|
RUN if [ "$NPM_STRICT_SSL" = "false" ]; then npm config set strict-ssl false; fi
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
ENV CI=true \
|
||||||
|
npm_config_maxsockets=1 \
|
||||||
|
NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||||
|
|
||||||
|
# Mitigate npm "Exit handler never called!" in some Docker/npm builds (see npm/cli issues).
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VITE_API_ORIGIN=
|
||||||
|
ENV VITE_API_ORIGIN=$VITE_API_ORIGIN
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG NPM_STRICT_SSL=true
|
||||||
|
RUN if [ "$NPM_STRICT_SSL" = "false" ]; then npm config set strict-ssl false; fi
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
ENV CI=true \
|
||||||
|
npm_config_maxsockets=1 \
|
||||||
|
NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||||
|
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY docker-entrypoint.dev.sh /docker-entrypoint.dev.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.dev.sh
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.dev.sh"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
args:
|
||||||
|
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
|
||||||
|
ports:
|
||||||
|
- '5173:5173'
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- client_node_modules:/app/node_modules
|
||||||
|
environment:
|
||||||
|
# Laravel API — Windows/macOS Docker Desktop resolves this; Linux uses extra_hosts below.
|
||||||
|
VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8000}
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
|
||||||
|
client-prod:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_ORIGIN: ${VITE_API_ORIGIN:-}
|
||||||
|
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
|
||||||
|
ports:
|
||||||
|
- '8080:80'
|
||||||
|
profiles:
|
||||||
|
- prod
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
client_node_modules:
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
if [ ! -f node_modules/.installed ]; then
|
||||||
|
export CI=true
|
||||||
|
export npm_config_maxsockets=1
|
||||||
|
export NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
touch node_modules/.installed
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5408
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "client_laravel",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test:api": "node scripts/test-api.mjs",
|
||||||
|
"gen:ci-meta": "node scripts/gen-ci-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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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 8000. Override if requests fail:
|
||||||
|
# export VITE_PROXY_API=http://172.17.0.1:8000
|
||||||
|
# ./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:8000}"
|
||||||
|
|
||||||
|
echo "Using VITE_PROXY_API=${VITE_PROXY_API}" >&2
|
||||||
|
echo "App → http://localhost:5173 (ensure Laravel is reachable from the container at the proxy target)" >&2
|
||||||
|
|
||||||
|
exec docker compose up --build client
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Generates src/lib/ciRouteMeta.generated.ts from CodeIgniter app/Views/*.php files.
|
||||||
|
* Run: node scripts/gen-ci-route-meta.mjs
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const viewsRoot = path.resolve(__dirname, '../../app_codeigniter/app/Views')
|
||||||
|
const outFile = path.resolve(__dirname, '../src/lib/ciRouteMeta.generated.ts')
|
||||||
|
|
||||||
|
const SKIP_DIRS = new Set([
|
||||||
|
'layout',
|
||||||
|
'partials',
|
||||||
|
'errors',
|
||||||
|
'pagination',
|
||||||
|
'docs',
|
||||||
|
])
|
||||||
|
|
||||||
|
function humanize(slug) {
|
||||||
|
const s = slug.replace(/\.php$/i, '').replace(/_/g, ' ')
|
||||||
|
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, baseRel) {
|
||||||
|
const out = []
|
||||||
|
let entries = []
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const rel = path.join(baseRel, e.name)
|
||||||
|
const full = path.join(dir, e.name)
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (SKIP_DIRS.has(e.name)) continue
|
||||||
|
out.push(...walk(full, rel))
|
||||||
|
} else if (e.isFile() && e.name.endsWith('.php')) {
|
||||||
|
out.push(rel.replace(/\\/g, '/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!fs.existsSync(viewsRoot)) {
|
||||||
|
console.error('Missing Views folder:', viewsRoot)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = walk(viewsRoot, '')
|
||||||
|
.filter((f) => !f.includes('/layout/'))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
for (const f of files) {
|
||||||
|
const parts = f.split('/')
|
||||||
|
const base = parts.pop().replace(/\.php$/i, '')
|
||||||
|
const section = parts.join('/') || '(root)'
|
||||||
|
const routeKey = [...parts, base].filter(Boolean).join('/')
|
||||||
|
const ciViewPath = f
|
||||||
|
const title = humanize(base)
|
||||||
|
entries.push({ routeKey, title, ciViewPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = []
|
||||||
|
lines.push(`/** Auto-generated by scripts/gen-ci-route-meta.mjs — do not edit by hand. */`)
|
||||||
|
lines.push(``)
|
||||||
|
lines.push(`export type CiRouteMetaEntry = {`)
|
||||||
|
lines.push(` title: string`)
|
||||||
|
lines.push(` ciViewPath: string`)
|
||||||
|
lines.push(`}`)
|
||||||
|
lines.push(``)
|
||||||
|
lines.push(`export const ciRouteMeta: Record<string, CiRouteMetaEntry> = {`)
|
||||||
|
|
||||||
|
for (const { routeKey, title, ciViewPath } of entries) {
|
||||||
|
const safeTitle = title.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
||||||
|
const safeView = ciViewPath.replace(/\\/g, '/')
|
||||||
|
lines.push(` '${routeKey}': { title: '${safeTitle}', ciViewPath: '${safeView}' },`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`}`)
|
||||||
|
lines.push(``)
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outFile), { recursive: true })
|
||||||
|
fs.writeFileSync(outFile, lines.join('\n'), 'utf8')
|
||||||
|
console.log(`Wrote ${entries.length} entries → ${path.relative(process.cwd(), outFile)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Smoke-test the same API routes the Vite client uses.
|
||||||
|
* Usage: node scripts/test-api.mjs
|
||||||
|
* $env:API_BASE="http://127.0.0.1:5173"; node scripts/test-api.mjs # via Vite proxy
|
||||||
|
*/
|
||||||
|
const base = (process.env.API_BASE ?? 'http://127.0.0.1:8000').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()
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Helvetica,
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-banner {
|
||||||
|
background-color: #15654f;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-banner span {
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-loading,
|
||||||
|
.docs-error {
|
||||||
|
padding: 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-error {
|
||||||
|
color: #b00020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-docs-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #e8f0ec;
|
||||||
|
border-bottom: 1px solid #c5d5ce;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-toolbar input {
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-toolbar a {
|
||||||
|
color: #15654f;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './auth/AuthProvider'
|
||||||
|
import { RequireAuth } from './auth/RequireAuth'
|
||||||
|
import { PublicLayout } from './layout/PublicLayout'
|
||||||
|
import { ManagementLayout } from './layout/ManagementLayout'
|
||||||
|
import { AdminDocs } from './pages/AdminDocs'
|
||||||
|
import { AppHome } from './pages/AppHome'
|
||||||
|
import { CiPlaceholderPage } from './pages/CiPlaceholderPage'
|
||||||
|
import { CiSitemapPage } from './pages/CiSitemapPage'
|
||||||
|
import { ForgotPasswordPage } from './pages/ForgotPasswordPage'
|
||||||
|
import { HomePage } from './pages/HomePage'
|
||||||
|
import { LoginPage } from './pages/LoginPage'
|
||||||
|
import { RegisterPage } from './pages/RegisterPage'
|
||||||
|
import { ParentAttendancePage } from './pages/parent/ParentAttendancePage'
|
||||||
|
import { ParentHomePage } from './pages/parent/ParentHomePage'
|
||||||
|
import {
|
||||||
|
ParentEmergencyContactEditPage,
|
||||||
|
ParentProgressAttachmentFilePage,
|
||||||
|
ParentProgressAttachmentPage,
|
||||||
|
ParentProgressViewPage,
|
||||||
|
ParentReportCardsSignPage,
|
||||||
|
ParentReportCardsViewPage,
|
||||||
|
ParentStudentEditPage,
|
||||||
|
} from './pages/parent/dynamicParityPages'
|
||||||
|
import {
|
||||||
|
ParentAddEmergencyFormPage,
|
||||||
|
ParentAddStudentFormPage,
|
||||||
|
ParentEditAllStudentsPage,
|
||||||
|
ParentUpdateProfileParityPage,
|
||||||
|
} from './pages/parent/extraParentParityPages'
|
||||||
|
import {
|
||||||
|
ParentAttendanceReportsHistoryPage,
|
||||||
|
ParentAuthorizedUsersPage,
|
||||||
|
} from './pages/parent/ParentAccountListPages'
|
||||||
|
import { ParentEmergencyContactsPage } from './pages/parent/ParentEmergencyContactsPage'
|
||||||
|
import {
|
||||||
|
RedirectParentLegacyEmergencyContactEdit,
|
||||||
|
RedirectParentLegacyStudentEdit,
|
||||||
|
} from './pages/parent/ParentCiRouteRedirects'
|
||||||
|
import {
|
||||||
|
ParentAddSecondParentPage,
|
||||||
|
ParentAssignmentsPage,
|
||||||
|
ParentCalendarPage,
|
||||||
|
ParentClassesPage,
|
||||||
|
ParentEnrollFailurePage,
|
||||||
|
ParentEnrollSuccessPage,
|
||||||
|
ParentInvoicePaymentPage,
|
||||||
|
ParentMessagePage,
|
||||||
|
ParentNoKidsRegisteredPage,
|
||||||
|
ParentPaymentSuccessPage,
|
||||||
|
ParentProgressListPage,
|
||||||
|
ParentReportCardsPage,
|
||||||
|
ParentScoresPage,
|
||||||
|
ParentSuccessMessagePage,
|
||||||
|
ParentWithdrawSuccessPage,
|
||||||
|
} from './pages/parent/staticParityPages'
|
||||||
|
import {
|
||||||
|
ParentContactPage,
|
||||||
|
ParentEnrollClassesPage,
|
||||||
|
ParentEventsPage,
|
||||||
|
ParentPaymentInvoicesPage,
|
||||||
|
ParentRegisterStudentPage,
|
||||||
|
ParentReportAttendancePage,
|
||||||
|
} from './pages/parent/WiredParentScreens'
|
||||||
|
import { SwaggerDocs } from './pages/SwaggerDocs'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<PublicLayout />}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/docs"
|
||||||
|
element={
|
||||||
|
<div className="docs-root-inner py-3">
|
||||||
|
<SwaggerDocs bootstrapPath="/api/docs/public" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/admin" element={<AdminDocs />} />
|
||||||
|
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route path="/app" element={<ManagementLayout />}>
|
||||||
|
<Route index element={<Navigate to="home" replace />} />
|
||||||
|
<Route path="home" element={<AppHome />} />
|
||||||
|
|
||||||
|
<Route path="parent/add_second_parent" element={<Navigate to="/app/parent/add-second-parent" replace />} />
|
||||||
|
<Route path="parent/register_student" element={<Navigate to="/app/parent/register-student" replace />} />
|
||||||
|
<Route path="parent/enroll_classes" element={<Navigate to="/app/parent/enroll-classes" replace />} />
|
||||||
|
<Route path="parent/enroll_success" element={<Navigate to="/app/parent/enroll/success" replace />} />
|
||||||
|
<Route path="parent/enroll_failure" element={<Navigate to="/app/parent/enroll/failure" replace />} />
|
||||||
|
<Route path="parent/invoice_payment" element={<Navigate to="/app/parent/invoice-payment" replace />} />
|
||||||
|
<Route path="parent/viewSecondParent" element={<Navigate to="/app/parent/add-second-parent" replace />} />
|
||||||
|
<Route path="parent/child_register" element={<Navigate to="/app/parent/register-student" replace />} />
|
||||||
|
|
||||||
|
<Route path="parent/edit_student/:studentId" element={<RedirectParentLegacyStudentEdit />} />
|
||||||
|
<Route path="parent/edit-student/:studentId" element={<RedirectParentLegacyStudentEdit />} />
|
||||||
|
|
||||||
|
<Route path="parent_dashboard" element={<Navigate to="/app/parent/home" replace />} />
|
||||||
|
|
||||||
|
<Route path="parent/report-cards/sign/:studentId" element={<ParentReportCardsSignPage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="parent/edit-emergency-contact/:contactId"
|
||||||
|
element={<RedirectParentLegacyEmergencyContactEdit />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="parent/edit_emergency_contact/:contactId"
|
||||||
|
element={<RedirectParentLegacyEmergencyContactEdit />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="parent/enroll/failure" element={<ParentEnrollFailurePage />} />
|
||||||
|
<Route path="parent/enroll/success" element={<ParentEnrollSuccessPage />} />
|
||||||
|
<Route path="parent/payment/success" element={<ParentPaymentSuccessPage />} />
|
||||||
|
<Route path="parent/withdraw/success" element={<ParentWithdrawSuccessPage />} />
|
||||||
|
|
||||||
|
<Route path="parent/report-cards/view/:reportId" element={<ParentReportCardsViewPage />} />
|
||||||
|
<Route path="parent/progress/view/:progressId" element={<ParentProgressViewPage />} />
|
||||||
|
<Route path="parent/progress/attachment/:attachmentId" element={<ParentProgressAttachmentPage />} />
|
||||||
|
<Route path="parent/progress/attachment-file/:fileId" element={<ParentProgressAttachmentFilePage />} />
|
||||||
|
|
||||||
|
<Route path="parent/emergency-contacts" element={<ParentEmergencyContactsPage />} />
|
||||||
|
<Route path="parent/edit-emergency-contact" element={<ParentEmergencyContactsPage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="parent/emergency-contact/:contactId"
|
||||||
|
element={<ParentEmergencyContactEditPage />}
|
||||||
|
/>
|
||||||
|
<Route path="parent/students/:studentId/edit" element={<ParentStudentEditPage />} />
|
||||||
|
|
||||||
|
<Route path="parent/edit-all-students" element={<ParentEditAllStudentsPage />} />
|
||||||
|
<Route path="parent/add-emergency-form" element={<ParentAddEmergencyFormPage />} />
|
||||||
|
<Route path="parent/add-student-form" element={<ParentAddStudentFormPage />} />
|
||||||
|
|
||||||
|
<Route path="parent/contact" element={<ParentContactPage />} />
|
||||||
|
<Route path="parent/enroll-classes" element={<ParentEnrollClassesPage />} />
|
||||||
|
<Route path="parent/events" element={<ParentEventsPage />} />
|
||||||
|
<Route path="parent/payment" element={<ParentPaymentInvoicesPage />} />
|
||||||
|
<Route path="parent/register-student" element={<ParentRegisterStudentPage />} />
|
||||||
|
<Route path="parent/report-attendance" element={<ParentReportAttendancePage />} />
|
||||||
|
<Route path="parent/attendance-reports" element={<ParentAttendanceReportsHistoryPage />} />
|
||||||
|
<Route path="parent/authorized-users" element={<ParentAuthorizedUsersPage />} />
|
||||||
|
<Route path="parent/update-profile" element={<ParentUpdateProfileParityPage />} />
|
||||||
|
|
||||||
|
<Route path="parent/home" element={<ParentHomePage />} />
|
||||||
|
<Route path="parent/attendance" element={<ParentAttendancePage />} />
|
||||||
|
<Route path="parent/add-second-parent" element={<ParentAddSecondParentPage />} />
|
||||||
|
<Route path="parent/assignments" element={<ParentAssignmentsPage />} />
|
||||||
|
<Route path="parent/calendar" element={<ParentCalendarPage />} />
|
||||||
|
<Route path="parent/classes" element={<ParentClassesPage />} />
|
||||||
|
<Route path="parent/progress" element={<ParentProgressListPage />} />
|
||||||
|
<Route path="parent/invoice-payment" element={<ParentInvoicePaymentPage />} />
|
||||||
|
<Route path="parent/no-kids-registered" element={<ParentNoKidsRegisteredPage />} />
|
||||||
|
<Route path="parent/message" element={<ParentMessagePage />} />
|
||||||
|
<Route path="parent/report-cards" element={<ParentReportCardsPage />} />
|
||||||
|
<Route path="parent/scores" element={<ParentScoresPage />} />
|
||||||
|
<Route path="parent/success" element={<ParentSuccessMessagePage />} />
|
||||||
|
|
||||||
|
<Route path="browse" element={<CiSitemapPage />} />
|
||||||
|
<Route path="*" element={<CiPlaceholderPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'alrahma_api_token'
|
||||||
|
|
||||||
|
export function getStoredToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredToken(token: string | null): void {
|
||||||
|
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
else localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiHttpError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
readonly body: unknown
|
||||||
|
|
||||||
|
constructor(message: string, status: number, body: unknown) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiHttpError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
opts: { attachAuth?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const attachAuth = opts.attachAuth !== false
|
||||||
|
const headers = new Headers(init.headers)
|
||||||
|
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
|
||||||
|
|
||||||
|
const token = getStoredToken()
|
||||||
|
if (attachAuth && token && !headers.has('Authorization')) {
|
||||||
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(apiUrl(path), { ...init, headers })
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
|
? String((body as { message?: unknown }).message ?? '')
|
||||||
|
: ''
|
||||||
|
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body as T
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
|
||||||
|
export type FullRegisterPayload = {
|
||||||
|
firstname: string
|
||||||
|
lastname: string
|
||||||
|
gender: string
|
||||||
|
cellphone: string
|
||||||
|
email: string
|
||||||
|
confirm_email: string
|
||||||
|
address_street: string
|
||||||
|
apt?: string
|
||||||
|
city: string
|
||||||
|
state: string
|
||||||
|
zip: string
|
||||||
|
is_parent: boolean
|
||||||
|
no_second_parent_info: boolean
|
||||||
|
second_firstname?: string
|
||||||
|
second_lastname?: string
|
||||||
|
second_gender?: string
|
||||||
|
second_email?: string
|
||||||
|
second_cellphone?: string
|
||||||
|
accept_school_policy: boolean
|
||||||
|
captcha: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRegisterCaptcha(): Promise<{ challenge: string }> {
|
||||||
|
const res = await fetch(apiUrl('/api/v1/auth/register/captcha'), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
if (!res.ok || !body || typeof body !== 'object') {
|
||||||
|
throw new Error('Could not load CAPTCHA.')
|
||||||
|
}
|
||||||
|
const o = body as { ok?: boolean; captcha?: string }
|
||||||
|
if (!o.ok || typeof o.captcha !== 'string') {
|
||||||
|
throw new Error('Invalid CAPTCHA response.')
|
||||||
|
}
|
||||||
|
return { challenge: o.captcha }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FullRegisterSuccess = {
|
||||||
|
ok: true
|
||||||
|
message?: string
|
||||||
|
registration?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FullRegisterFailure = {
|
||||||
|
ok: false
|
||||||
|
message: string
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitFullRegistration(
|
||||||
|
payload: FullRegisterPayload,
|
||||||
|
): Promise<FullRegisterSuccess | FullRegisterFailure> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
firstname: payload.firstname.trim(),
|
||||||
|
lastname: payload.lastname.trim(),
|
||||||
|
gender: payload.gender,
|
||||||
|
cellphone: payload.cellphone.trim(),
|
||||||
|
email: payload.email.trim(),
|
||||||
|
confirm_email: payload.confirm_email.trim(),
|
||||||
|
address_street: payload.address_street.trim(),
|
||||||
|
apt: payload.apt?.trim() || '',
|
||||||
|
city: payload.city.trim(),
|
||||||
|
state: payload.state,
|
||||||
|
zip: payload.zip.trim(),
|
||||||
|
is_parent: payload.is_parent,
|
||||||
|
no_second_parent_info: payload.no_second_parent_info,
|
||||||
|
accept_school_policy: payload.accept_school_policy,
|
||||||
|
captcha: payload.captcha.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.is_parent && !payload.no_second_parent_info) {
|
||||||
|
body.second_firstname = payload.second_firstname?.trim() ?? ''
|
||||||
|
body.second_lastname = payload.second_lastname?.trim() ?? ''
|
||||||
|
body.second_gender = payload.second_gender ?? ''
|
||||||
|
body.second_email = payload.second_email?.trim() ?? ''
|
||||||
|
body.second_cellphone = payload.second_cellphone?.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(apiUrl('/api/v1/auth/register'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: unknown = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (res.ok && data && typeof data === 'object' && 'ok' in data && (data as { ok?: boolean }).ok === true) {
|
||||||
|
return { ok: true, message: (data as { message?: string }).message, registration: (data as { registration?: unknown }).registration }
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = 'Registration failed.'
|
||||||
|
let errors: Record<string, string[]> | undefined
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const o = data as { message?: string; errors?: Record<string, string[] | string> }
|
||||||
|
if (typeof o.message === 'string') message = o.message
|
||||||
|
if (o.errors && typeof o.errors === 'object') {
|
||||||
|
errors = {}
|
||||||
|
for (const [k, v] of Object.entries(o.errors)) {
|
||||||
|
if (Array.isArray(v)) errors[k] = v.map(String)
|
||||||
|
else errors[k] = [String(v)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSchoolPolicyHtml(): Promise<string> {
|
||||||
|
const res = await fetch(apiUrl('/api/v1/policies/school'), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
const json = (await res.json().catch(() => null)) as {
|
||||||
|
data?: { policy?: { content?: string } }
|
||||||
|
}
|
||||||
|
const html = json?.data?.policy?.content
|
||||||
|
if (!res.ok || !html) {
|
||||||
|
return '<p>Unable to load policy. Please contact the school.</p>'
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { apiFetch } from './http'
|
||||||
|
import type {
|
||||||
|
ApiEnvelope,
|
||||||
|
DashboardPayload,
|
||||||
|
LoginFailure,
|
||||||
|
LoginResponse,
|
||||||
|
LoginSuccess,
|
||||||
|
NavMenuResponse,
|
||||||
|
ParentAttendanceResponse,
|
||||||
|
ParentAttendanceReportFormResponse,
|
||||||
|
ParentEnrollmentsResponse,
|
||||||
|
ParentEventsResponse,
|
||||||
|
ParentInvoicesResponse,
|
||||||
|
ParentProfileResponse,
|
||||||
|
ParentRegistrationResponse,
|
||||||
|
ParentEmergencyContactsResponse,
|
||||||
|
ParentAttendanceReportsListResponse,
|
||||||
|
ParentAuthorizedUsersApiResponse,
|
||||||
|
ContactSubmitResponse,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export async function loginRequest(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const body = await apiFetch<LoginResponse>(
|
||||||
|
'/api/v1/auth/login',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
},
|
||||||
|
{ attachAuth: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (body.status === false) {
|
||||||
|
const fail = body as LoginFailure
|
||||||
|
return { status: false, message: fail.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
return body as LoginSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayload>> {
|
||||||
|
return apiFetch<ApiEnvelope<DashboardPayload>>('/api/v1/dashboard/route')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNavMenu(): Promise<NavMenuResponse> {
|
||||||
|
return apiFetch<NavMenuResponse>('/api/v1/nav-builder/menu')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentAttendance(
|
||||||
|
schoolYear: string | null,
|
||||||
|
): Promise<ParentAttendanceResponse> {
|
||||||
|
const q = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
|
||||||
|
return apiFetch<ParentAttendanceResponse>(`/api/v1/parents/attendance${q}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentProfile(): Promise<ParentProfileResponse> {
|
||||||
|
return apiFetch<ParentProfileResponse>('/api/v1/parents/profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentEnrollments(
|
||||||
|
schoolYear: string | null,
|
||||||
|
): Promise<ParentEnrollmentsResponse> {
|
||||||
|
const q = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
|
||||||
|
return apiFetch<ParentEnrollmentsResponse>(`/api/v1/parents/enrollments${q}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentInvoices(
|
||||||
|
schoolYear: string | null,
|
||||||
|
): Promise<ParentInvoicesResponse> {
|
||||||
|
const q = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
|
||||||
|
return apiFetch<ParentInvoicesResponse>(`/api/v1/parents/invoices${q}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentEventsOverview(): Promise<ParentEventsResponse> {
|
||||||
|
return apiFetch<ParentEventsResponse>('/api/v1/parents/events')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentRegistrationOverview(): Promise<ParentRegistrationResponse> {
|
||||||
|
return apiFetch<ParentRegistrationResponse>('/api/v1/parents/registration')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAttendanceReportForm(): Promise<ParentAttendanceReportFormResponse> {
|
||||||
|
return apiFetch<ParentAttendanceReportFormResponse>(
|
||||||
|
'/api/v1/parents/attendance-reports/form',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitContactMessage(payload: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
subject: string
|
||||||
|
message: string
|
||||||
|
}): Promise<ContactSubmitResponse> {
|
||||||
|
return apiFetch<ContactSubmitResponse>('/api/v1/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentEmergencyContacts(): Promise<ParentEmergencyContactsResponse> {
|
||||||
|
return apiFetch<ParentEmergencyContactsResponse>('/api/v1/parents/emergency-contacts')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentAttendanceReportsList(
|
||||||
|
query?: Record<string, string>,
|
||||||
|
): Promise<ParentAttendanceReportsListResponse> {
|
||||||
|
const q =
|
||||||
|
query && Object.keys(query).length > 0
|
||||||
|
? `?${new URLSearchParams(query).toString()}`
|
||||||
|
: ''
|
||||||
|
return apiFetch<ParentAttendanceReportsListResponse>(
|
||||||
|
`/api/v1/parents/attendance-reports${q}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParentAuthorizedUsers(): Promise<ParentAuthorizedUsersApiResponse> {
|
||||||
|
return apiFetch<ParentAuthorizedUsersApiResponse>('/api/v1/parents/authorized-users')
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export type AuthUser = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
roles: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginSuccess = {
|
||||||
|
status: true
|
||||||
|
token: string
|
||||||
|
user: AuthUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginFailure = {
|
||||||
|
status: false
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginResponse = LoginSuccess | LoginFailure
|
||||||
|
|
||||||
|
export type ApiEnvelope<T> = {
|
||||||
|
status: boolean
|
||||||
|
message?: string
|
||||||
|
data?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardPayload = {
|
||||||
|
dashboard: {
|
||||||
|
route: string | null
|
||||||
|
role: string | null
|
||||||
|
role_slug: string | null
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
id: number
|
||||||
|
menu_parent_id: number | null
|
||||||
|
label: string
|
||||||
|
url: string | null
|
||||||
|
icon_class: string | null
|
||||||
|
target: string | null
|
||||||
|
sort_order: number
|
||||||
|
is_enabled: number
|
||||||
|
children: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavMenuResponse = ApiEnvelope<{ items: NavItem[] }>
|
||||||
|
|
||||||
|
export type ParentAttendanceRow = {
|
||||||
|
firstname: string
|
||||||
|
lastname: string
|
||||||
|
date: string
|
||||||
|
status: string
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentAttendanceResponse = {
|
||||||
|
ok: true
|
||||||
|
attendance: ParentAttendanceRow[]
|
||||||
|
schoolYears: { school_year: string }[]
|
||||||
|
selectedYear: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laravel `User::toArray()` for the logged-in parent (password hidden server-side). */
|
||||||
|
export type ParentProfileRecord = {
|
||||||
|
id?: number
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
email?: string | null
|
||||||
|
cellphone?: string | null
|
||||||
|
city?: string | null
|
||||||
|
state?: string | null
|
||||||
|
zip?: string | null
|
||||||
|
address_street?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentProfileResponse = {
|
||||||
|
ok: true
|
||||||
|
profile: ParentProfileRecord | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentInvoiceRow = {
|
||||||
|
id: number
|
||||||
|
invoice_number?: string | null
|
||||||
|
balance?: number
|
||||||
|
status?: string | null
|
||||||
|
due_date?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentInvoicesResponse = {
|
||||||
|
ok: true
|
||||||
|
invoices: ParentInvoiceRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentEnrollmentStudent = {
|
||||||
|
id: number
|
||||||
|
firstname: string
|
||||||
|
lastname: string
|
||||||
|
class_section?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentEnrollmentsResponse = {
|
||||||
|
ok: true
|
||||||
|
students: ParentEnrollmentStudent[]
|
||||||
|
schoolYears: { school_year: string }[]
|
||||||
|
selectedYear: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContactSubmitResponse = {
|
||||||
|
status: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loose JSON payloads for complex parent dashboards (events, registration, attendance report form). */
|
||||||
|
export type ParentEventsResponse = Record<string, unknown>
|
||||||
|
export type ParentRegistrationResponse = Record<string, unknown>
|
||||||
|
export type ParentAttendanceReportFormResponse = Record<string, unknown>
|
||||||
|
|
||||||
|
export type ParentEmergencyContactRow = {
|
||||||
|
id: number
|
||||||
|
name?: string
|
||||||
|
cellphone?: string
|
||||||
|
email?: string | null
|
||||||
|
relation?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentEmergencyContactsResponse = {
|
||||||
|
ok: true
|
||||||
|
contacts: ParentEmergencyContactRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentAttendanceReportsListResponse = {
|
||||||
|
ok: true
|
||||||
|
reports: Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/v1/parents/authorized-users` — envelope from BaseApiController. */
|
||||||
|
export type ParentAuthorizedUsersApiResponse = ApiEnvelope<unknown[]>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from 'react'
|
||||||
|
import { 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
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_USER = 'alrahma_api_user'
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
let memory: AuthState = {
|
||||||
|
token: getStoredToken(),
|
||||||
|
user: readUser(),
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
memory = {
|
||||||
|
token: getStoredToken(),
|
||||||
|
user: readUser(),
|
||||||
|
}
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const result = await loginRequest(email, password)
|
||||||
|
if (result.status === false) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
message: result.message ?? 'Login failed.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStoredToken(result.token)
|
||||||
|
writeUser(result.user)
|
||||||
|
setAuth({ token: result.token, user: result.user })
|
||||||
|
return { ok: true as const }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setStoredToken(null)
|
||||||
|
writeUser(null)
|
||||||
|
setAuth({ token: null, user: null })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSession = useCallback((token: string, user: AuthUser) => {
|
||||||
|
setStoredToken(token)
|
||||||
|
writeUser(user)
|
||||||
|
setAuth({ token, user })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setSession,
|
||||||
|
token: state.token,
|
||||||
|
user: state.user,
|
||||||
|
isAuthenticated: Boolean(state.token && state.user),
|
||||||
|
}),
|
||||||
|
[state.token, state.user, login, logout, setSession],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
login: (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
|
logout: () => void
|
||||||
|
setSession: (token: string, user: AuthUser) => void
|
||||||
|
token: string | null
|
||||||
|
user: AuthUser | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from './AuthProvider'
|
||||||
|
|
||||||
|
export function RequireAuth() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Northeast states — same labels as CodeIgniter user/register.php */
|
||||||
|
export const NE_STATES: { abbr: string; name: string }[] = [
|
||||||
|
{ abbr: 'CT', name: 'Connecticut' },
|
||||||
|
{ abbr: 'ME', name: 'Maine' },
|
||||||
|
{ abbr: 'MA', name: 'Massachusetts' },
|
||||||
|
{ abbr: 'NH', name: 'New Hampshire' },
|
||||||
|
{ abbr: 'NY', name: 'New York' },
|
||||||
|
{ abbr: 'RI', name: 'Rhode Island' },
|
||||||
|
{ abbr: 'VT', name: 'Vermont' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
:root {
|
||||||
|
/* Mirrors CodeIgniter management_layout theme tokens */
|
||||||
|
--mgmt-primary: #22a047;
|
||||||
|
--mgmt-primary-hover: #1c863b;
|
||||||
|
--mgmt-text: #1f2937;
|
||||||
|
--mgmt-muted: #64748b;
|
||||||
|
--mgmt-bg: #f5f7fb;
|
||||||
|
|
||||||
|
font-family:
|
||||||
|
Heebo,
|
||||||
|
system-ui,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
color: var(--mgmt-text);
|
||||||
|
background-color: var(--mgmt-bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100svh;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swagger / docs pages (narrow column) */
|
||||||
|
.docs-root-inner {
|
||||||
|
max-width: 1126px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-app {
|
||||||
|
font-family: Heebo, system-ui, sans-serif;
|
||||||
|
background-color: var(--mgmt-bg);
|
||||||
|
color: var(--mgmt-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-app .navbar {
|
||||||
|
background-color: #14532d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-app .navbar .navbar-brand,
|
||||||
|
.management-app .navbar .navbar-text {
|
||||||
|
color: rgba(255, 255, 255, 0.95) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-main {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.management-app .nav-link.active {
|
||||||
|
background-color: rgba(34, 160, 71, 0.12);
|
||||||
|
color: var(--mgmt-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page,
|
||||||
|
.register-page {
|
||||||
|
background: linear-gradient(160deg, #ecfdf3 0%, var(--mgmt-bg) 45%, #fff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-shell .hero-gradient {
|
||||||
|
background: linear-gradient(135deg, #14532d 0%, #166534 45%, #15803d 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preserve docs styling from App.css */
|
||||||
|
.docs-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
|
import { fetchNavMenu } from '../api/session'
|
||||||
|
import type { NavItem } from '../api/types'
|
||||||
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import {
|
||||||
|
isExternalNavUrl,
|
||||||
|
spaPathFromCiUrl,
|
||||||
|
} from '../lib/ciSpaPaths'
|
||||||
|
|
||||||
|
function normalizeNavItems(payload: unknown): NavItem[] {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload as NavItem[]
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === 'object' &&
|
||||||
|
'data' in payload &&
|
||||||
|
Array.isArray((payload as { data: unknown }).data)
|
||||||
|
) {
|
||||||
|
return (payload as { data: NavItem[] }).data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavTree({ items }: { items: NavItem[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="nav flex-column gap-1 ps-0 mb-0 list-unstyled">
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavBranch key={item.id} item={item} depth={0} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavBranch({ item, depth }: { item: NavItem; depth: number }) {
|
||||||
|
const enabled = item.is_enabled !== 0
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
|
const label = item.label?.trim() || 'Menu'
|
||||||
|
const url = item.url?.trim() || ''
|
||||||
|
const external = isExternalNavUrl(url)
|
||||||
|
const spa = spaPathFromCiUrl(url)
|
||||||
|
const pad = 8 + depth * 10
|
||||||
|
|
||||||
|
const icon = item.icon_class ? (
|
||||||
|
<i className={`${item.icon_class} me-2`} aria-hidden />
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const linkInner = (
|
||||||
|
<>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
`nav-link rounded py-2 px-2 ${isActive ? 'active fw-semibold' : ''}`
|
||||||
|
|
||||||
|
let main: ReactNode
|
||||||
|
if (external) {
|
||||||
|
main = (
|
||||||
|
<a
|
||||||
|
className="nav-link rounded py-2 px-2"
|
||||||
|
href={url}
|
||||||
|
target={item.target || '_self'}
|
||||||
|
rel={item.target === '_blank' ? 'noreferrer noopener' : undefined}
|
||||||
|
style={{ paddingLeft: pad }}
|
||||||
|
>
|
||||||
|
{linkInner}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
} else if (spa) {
|
||||||
|
main = (
|
||||||
|
<NavLink end to={spa} className={linkClass} style={{ paddingLeft: pad }}>
|
||||||
|
{linkInner}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
main = (
|
||||||
|
<span className="nav-link disabled" style={{ paddingLeft: pad }}>
|
||||||
|
{linkInner}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = (item.children ?? []).filter((c) => c.is_enabled !== 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
{main}
|
||||||
|
{children.length > 0 ? (
|
||||||
|
<div className="mt-1">
|
||||||
|
{children.map((ch) => (
|
||||||
|
<NavBranch key={ch.id} item={ch} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManagementLayout() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [items, setItems] = useState<NavItem[]>([])
|
||||||
|
const [menuError, setMenuError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchNavMenu()
|
||||||
|
const list = res.data?.items
|
||||||
|
if (!cancelled && list != null) {
|
||||||
|
setItems(normalizeNavItems(list))
|
||||||
|
setMenuError(null)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setItems([])
|
||||||
|
setMenuError(e instanceof Error ? e.message : 'Unable to load menu.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="management-app d-flex flex-column min-vh-100">
|
||||||
|
<header className="navbar navbar-dark border-bottom shadow-sm px-3 py-2">
|
||||||
|
<div className="container-fluid px-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<Link className="navbar-brand fw-semibold mb-0" to="/app/home">
|
||||||
|
Al Rahma Sunday School
|
||||||
|
</Link>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span className="navbar-text small mb-0">{user?.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-light btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
logout()
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="container-fluid flex-grow-1 py-3">
|
||||||
|
<div className="row g-3">
|
||||||
|
<aside className="col-12 col-md-3 col-xl-2">
|
||||||
|
<div className="border rounded bg-white shadow-sm">
|
||||||
|
<div className="p-3 small text-muted border-bottom">Navigation</div>
|
||||||
|
<nav className="p-2 pb-3">
|
||||||
|
<NavLink
|
||||||
|
to="/app/home"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link rounded mb-2 ${isActive ? 'active fw-semibold' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i className="bi bi-house-door me-2" aria-hidden />
|
||||||
|
Home
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/app/browse"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link rounded mb-2 ${isActive ? 'active fw-semibold' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i className="bi bi-diagram-3 me-2" aria-hidden />
|
||||||
|
CI views
|
||||||
|
</NavLink>
|
||||||
|
{menuError ? (
|
||||||
|
<div className="alert alert-warning small py-2">{menuError}</div>
|
||||||
|
) : (
|
||||||
|
<NavTree items={items} />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="col-12 col-md-9 col-xl-10 management-main">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Link, Outlet } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
|
||||||
|
export function PublicLayout() {
|
||||||
|
const { isAuthenticated, user, logout } = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="d-flex flex-column min-vh-100 public-shell">
|
||||||
|
<nav
|
||||||
|
className="navbar navbar-expand-lg navbar-dark shadow-sm"
|
||||||
|
style={{ backgroundColor: '#2c9b09' }}
|
||||||
|
>
|
||||||
|
<div className="container">
|
||||||
|
<Link className="navbar-brand fw-semibold" to="/">
|
||||||
|
Al Rahma Sunday School
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#publicNav"
|
||||||
|
aria-controls="publicNav"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<span className="navbar-toggler-icon" />
|
||||||
|
</button>
|
||||||
|
<div className="collapse navbar-collapse justify-content-end" id="publicNav">
|
||||||
|
<ul className="navbar-nav align-items-lg-center gap-lg-2">
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="nav-link" to="/">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="nav-link" to="/docs">
|
||||||
|
API docs
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<li className="nav-item">
|
||||||
|
<span className="navbar-text small px-2">{user?.name}</span>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="btn btn-outline-light btn-sm" to="/app/home">
|
||||||
|
Portal
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link nav-link text-white text-decoration-none"
|
||||||
|
onClick={() => {
|
||||||
|
logout()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="nav-link" to="/login">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<Link className="btn btn-outline-light btn-sm" to="/register">
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="flex-grow-1 d-flex flex-column">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-top bg-white py-4 mt-auto">
|
||||||
|
<div className="container text-center text-muted small">
|
||||||
|
<p className="mb-0">Al Rahma Sunday School</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Prefix for Laravel API when the SPA is served from another origin (production). Empty in dev with Vite proxy. */
|
||||||
|
export function apiOrigin(): string {
|
||||||
|
const v = import.meta.env.VITE_API_ORIGIN
|
||||||
|
return typeof v === 'string' ? v.replace(/\/$/, '') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiUrl(path: string): string {
|
||||||
|
const base = apiOrigin()
|
||||||
|
const p = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${base}${p}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { type CiRouteMetaEntry, ciRouteMeta } from './ciRouteMeta.generated'
|
||||||
|
import { ciRouteManual } from './ciRouteManual'
|
||||||
|
|
||||||
|
/** Match CI `app/Views/...` keys: lowercase, hyphens in URL map to underscores. */
|
||||||
|
export function getCiMeta(splat: string): CiRouteMetaEntry | null {
|
||||||
|
const raw = splat
|
||||||
|
.trim()
|
||||||
|
.replace(/^\/+|\/+$/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
if (!raw) return null
|
||||||
|
const withUnderscores = raw.replace(/-/g, '_')
|
||||||
|
return (
|
||||||
|
ciRouteManual[withUnderscores] ?? ciRouteMeta[withUnderscores] ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build canonical SPA path from a registry key (snake segments) using kebab-case segments. */
|
||||||
|
export function ciRegistryKeyToAppPath(routeKey: string): string {
|
||||||
|
const parts = routeKey.split('/').map((seg) => seg.replace(/_/g, '-'))
|
||||||
|
return `/app/${parts.join('/')}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { CiRouteMetaEntry } from './ciRouteMeta.generated'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CI routes that do not map 1:1 to a single Views/*.php filename (composed dashboards, aliases).
|
||||||
|
* Keys use snake_case segments like the generated registry.
|
||||||
|
*/
|
||||||
|
export const ciRouteManual: Record<string, CiRouteMetaEntry> = {
|
||||||
|
'teacher/dashboard': {
|
||||||
|
title: 'Teacher dashboard',
|
||||||
|
ciViewPath: '(TeacherController::dashboard — see Views/teacher/*.php)',
|
||||||
|
},
|
||||||
|
'administrator/dashboard': {
|
||||||
|
title: 'Administrator dashboard',
|
||||||
|
ciViewPath: '(Support/administrator dashboards — see Views/administrator/*.php)',
|
||||||
|
},
|
||||||
|
'administrator/administratordashboard': {
|
||||||
|
title: 'Administrator dashboard',
|
||||||
|
ciViewPath: 'administrator/administratordashboard.php',
|
||||||
|
},
|
||||||
|
'landing_page/guest_dashboard': {
|
||||||
|
title: 'Guest dashboard',
|
||||||
|
ciViewPath: 'landing_page/guest_dashboard.php',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/** Auto-generated by scripts/gen-ci-route-meta.mjs — do not edit by hand. */
|
||||||
|
|
||||||
|
export type CiRouteMetaEntry = {
|
||||||
|
title: string
|
||||||
|
ciViewPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ciRouteMeta: Record<string, CiRouteMetaEntry> = {
|
||||||
|
'about': { title: 'About', ciViewPath: 'about.php' },
|
||||||
|
'account/choose_account': { title: 'Choose Account', ciViewPath: 'account/choose_account.php' },
|
||||||
|
'activate_account': { title: 'Activate Account', ciViewPath: 'activate_account.php' },
|
||||||
|
'admin/class_progress_list': { title: 'Class Progress List', ciViewPath: 'admin/class_progress_list.php' },
|
||||||
|
'admin/class_progress_view': { title: 'Class Progress View', ciViewPath: 'admin/class_progress_view.php' },
|
||||||
|
'admin/competition_winners/form': { title: 'Form', ciViewPath: 'admin/competition_winners/form.php' },
|
||||||
|
'admin/competition_winners/index': { title: 'Index', ciViewPath: 'admin/competition_winners/index.php' },
|
||||||
|
'admin/competition_winners/preview': { title: 'Preview', ciViewPath: 'admin/competition_winners/preview.php' },
|
||||||
|
'admin/competition_winners/scores': { title: 'Scores', ciViewPath: 'admin/competition_winners/scores.php' },
|
||||||
|
'admin/competition_winners/winners': { title: 'Winners', ciViewPath: 'admin/competition_winners/winners.php' },
|
||||||
|
'admin/student_score_card': { title: 'Student Score Card', ciViewPath: 'admin/student_score_card.php' },
|
||||||
|
'administrator/absence_vacation': { title: 'Absence Vacation', ciViewPath: 'administrator/absence_vacation.php' },
|
||||||
|
'administrator/academic_performance': { title: 'Academic Performance', ciViewPath: 'administrator/academic_performance.php' },
|
||||||
|
'administrator/admin_attendance_records': { title: 'Admin Attendance Records', ciViewPath: 'administrator/admin_attendance_records.php' },
|
||||||
|
'administrator/admin_profiles': { title: 'Admin Profiles', ciViewPath: 'administrator/admin_profiles.php' },
|
||||||
|
'administrator/administratordashboard': { title: 'Administratordashboard', ciViewPath: 'administrator/administratordashboard.php' },
|
||||||
|
'administrator/attendance': { title: 'Attendance', ciViewPath: 'administrator/attendance.php' },
|
||||||
|
'administrator/attendance_records': { title: 'Attendance Records', ciViewPath: 'administrator/attendance_records.php' },
|
||||||
|
'administrator/behavior_reports': { title: 'Behavior Reports', ciViewPath: 'administrator/behavior_reports.php' },
|
||||||
|
'administrator/broadcast_email': { title: 'Broadcast Email', ciViewPath: 'administrator/broadcast_email.php' },
|
||||||
|
'administrator/budget_reports': { title: 'Budget Reports', ciViewPath: 'administrator/budget_reports.php' },
|
||||||
|
'administrator/calendar': { title: 'Calendar', ciViewPath: 'administrator/calendar.php' },
|
||||||
|
'administrator/calendar_add_event': { title: 'Calendar Add Event', ciViewPath: 'administrator/calendar_add_event.php' },
|
||||||
|
'administrator/calendar_edit': { title: 'Calendar Edit', ciViewPath: 'administrator/calendar_edit.php' },
|
||||||
|
'administrator/calendar_view': { title: 'Calendar View', ciViewPath: 'administrator/calendar_view.php' },
|
||||||
|
'administrator/class_assignment': { title: 'Class Assignment', ciViewPath: 'administrator/class_assignment.php' },
|
||||||
|
'administrator/class_section': { title: 'Class Section', ciViewPath: 'administrator/class_section.php' },
|
||||||
|
'administrator/class_warning_modal': { title: 'Class Warning Modal', ciViewPath: 'administrator/class_warning_modal.php' },
|
||||||
|
'administrator/communication_logs': { title: 'Communication Logs', ciViewPath: 'administrator/communication_logs.php' },
|
||||||
|
'administrator/course_materials': { title: 'Course Materials', ciViewPath: 'administrator/course_materials.php' },
|
||||||
|
'administrator/create_parent': { title: 'Create Parent', ciViewPath: 'administrator/create_parent.php' },
|
||||||
|
'administrator/daily_attendance': { title: 'Daily Attendance', ciViewPath: 'administrator/daily_attendance.php' },
|
||||||
|
'administrator/daily_attendance_analysis': { title: 'Daily Attendance Analysis', ciViewPath: 'administrator/daily_attendance_analysis.php' },
|
||||||
|
'administrator/edit_parent': { title: 'Edit Parent', ciViewPath: 'administrator/edit_parent.php' },
|
||||||
|
'administrator/edit_teacher': { title: 'Edit Teacher', ciViewPath: 'administrator/edit_teacher.php' },
|
||||||
|
'administrator/emergency_contact/edit': { title: 'Edit', ciViewPath: 'administrator/emergency_contact/edit.php' },
|
||||||
|
'administrator/emergency_contact/index': { title: 'Index', ciViewPath: 'administrator/emergency_contact/index.php' },
|
||||||
|
'administrator/events/create_event': { title: 'Create Event', ciViewPath: 'administrator/events/create_event.php' },
|
||||||
|
'administrator/events/edit_event': { title: 'Edit Event', ciViewPath: 'administrator/events/edit_event.php' },
|
||||||
|
'administrator/events/event_charges': { title: 'Event Charges', ciViewPath: 'administrator/events/event_charges.php' },
|
||||||
|
'administrator/events/event_list': { title: 'Event List', ciViewPath: 'administrator/events/event_list.php' },
|
||||||
|
'administrator/exam_drafts': { title: 'Exam Drafts', ciViewPath: 'administrator/exam_drafts.php' },
|
||||||
|
'administrator/expense_management': { title: 'Expense Management', ciViewPath: 'administrator/expense_management.php' },
|
||||||
|
'administrator/fee_collection': { title: 'Fee Collection', ciViewPath: 'administrator/fee_collection.php' },
|
||||||
|
'administrator/fee_payment_records': { title: 'Fee Payment Records', ciViewPath: 'administrator/fee_payment_records.php' },
|
||||||
|
'administrator/feedback_complaints': { title: 'Feedback Complaints', ciViewPath: 'administrator/feedback_complaints.php' },
|
||||||
|
'administrator/grades': { title: 'Grades', ciViewPath: 'administrator/grades.php' },
|
||||||
|
'administrator/grading_management': { title: 'Grading Management', ciViewPath: 'administrator/grading_management.php' },
|
||||||
|
'administrator/health_records': { title: 'Health Records', ciViewPath: 'administrator/health_records.php' },
|
||||||
|
'administrator/index': { title: 'Index', ciViewPath: 'administrator/index.php' },
|
||||||
|
'administrator/ip_bans': { title: 'Ip Bans', ciViewPath: 'administrator/ip_bans.php' },
|
||||||
|
'administrator/late_slip_logs': { title: 'Late Slip Logs', ciViewPath: 'administrator/late_slip_logs.php' },
|
||||||
|
'administrator/manage_users': { title: 'Manage Users', ciViewPath: 'administrator/manage_users.php' },
|
||||||
|
'administrator/notifications_alerts': { title: 'Notifications Alerts', ciViewPath: 'administrator/notifications_alerts.php' },
|
||||||
|
'administrator/parent': { title: 'Parent', ciViewPath: 'administrator/parent.php' },
|
||||||
|
'administrator/parent_profile': { title: 'Parent Profile', ciViewPath: 'administrator/parent_profile.php' },
|
||||||
|
'administrator/paypal_transactions': { title: 'Paypal Transactions', ciViewPath: 'administrator/paypal_transactions.php' },
|
||||||
|
'administrator/payroll_management': { title: 'Payroll Management', ciViewPath: 'administrator/payroll_management.php' },
|
||||||
|
'administrator/performance_reviews': { title: 'Performance Reviews', ciViewPath: 'administrator/performance_reviews.php' },
|
||||||
|
'administrator/print_notification_admins': { title: 'Print Notification Admins', ciViewPath: 'administrator/print_notification_admins.php' },
|
||||||
|
'administrator/removed_students': { title: 'Removed Students', ciViewPath: 'administrator/removed_students.php' },
|
||||||
|
'administrator/scholarship_information': { title: 'Scholarship Information', ciViewPath: 'administrator/scholarship_information.php' },
|
||||||
|
'administrator/search_results': { title: 'Search Results', ciViewPath: 'administrator/search_results.php' },
|
||||||
|
'administrator/sections_auto_distribute': { title: 'Sections Auto Distribute', ciViewPath: 'administrator/sections_auto_distribute.php' },
|
||||||
|
'administrator/settings': { title: 'Settings', ciViewPath: 'administrator/settings.php' },
|
||||||
|
'administrator/student_class_assignment': { title: 'Student Class Assignment', ciViewPath: 'administrator/student_class_assignment.php' },
|
||||||
|
'administrator/student_profiles': { title: 'Student Profiles', ciViewPath: 'administrator/student_profiles.php' },
|
||||||
|
'administrator/subject_curriculum': { title: 'Subject Curriculum', ciViewPath: 'administrator/subject_curriculum.php' },
|
||||||
|
'administrator/teacher_class_assignment': { title: 'Teacher Class Assignment', ciViewPath: 'administrator/teacher_class_assignment.php' },
|
||||||
|
'administrator/teacher_submissions': { title: 'Teacher Submissions', ciViewPath: 'administrator/teacher_submissions.php' },
|
||||||
|
'administrator/teachers': { title: 'Teachers', ciViewPath: 'administrator/teachers.php' },
|
||||||
|
'appointment': { title: 'Appointment', ciViewPath: 'appointment.php' },
|
||||||
|
'attendance/admins_attendance_form': { title: 'Admins Attendance Form', ciViewPath: 'attendance/admins_attendance_form.php' },
|
||||||
|
'attendance/attendance_tracking': { title: 'Attendance Tracking', ciViewPath: 'attendance/attendance_tracking.php' },
|
||||||
|
'attendance/compose_email': { title: 'Compose Email', ciViewPath: 'attendance/compose_email.php' },
|
||||||
|
'attendance/early_dismissals': { title: 'Early Dismissals', ciViewPath: 'attendance/early_dismissals.php' },
|
||||||
|
'attendance/early_dismissals_add': { title: 'Early Dismissals Add', ciViewPath: 'attendance/early_dismissals_add.php' },
|
||||||
|
'attendance/edit_modal': { title: 'Edit Modal', ciViewPath: 'attendance/edit_modal.php' },
|
||||||
|
'attendance/parent_reports': { title: 'Parent Reports', ciViewPath: 'attendance/parent_reports.php' },
|
||||||
|
'attendance/teacher_attendance_form': { title: 'Teacher Attendance Form', ciViewPath: 'attendance/teacher_attendance_form.php' },
|
||||||
|
'attendance/teacher_attendance_month': { title: 'Teacher Attendance Month', ciViewPath: 'attendance/teacher_attendance_month.php' },
|
||||||
|
'attendance/view': { title: 'View', ciViewPath: 'attendance/view.php' },
|
||||||
|
'attendance/violations_notified': { title: 'Violations Notified', ciViewPath: 'attendance/violations_notified.php' },
|
||||||
|
'attendance/violations_pending': { title: 'Violations Pending', ciViewPath: 'attendance/violations_pending.php' },
|
||||||
|
'attendance_templates/index': { title: 'Index', ciViewPath: 'attendance_templates/index.php' },
|
||||||
|
'auth/select_role': { title: 'Select Role', ciViewPath: 'auth/select_role.php' },
|
||||||
|
'calendar': { title: 'Calendar', ciViewPath: 'calendar.php' },
|
||||||
|
'call_to_action': { title: 'Call To Action', ciViewPath: 'call_to_action.php' },
|
||||||
|
'class_prep/list': { title: 'List', ciViewPath: 'class_prep/list.php' },
|
||||||
|
'class_prep/print': { title: 'Print', ciViewPath: 'class_prep/print.php' },
|
||||||
|
'classes': { title: 'Classes', ciViewPath: 'classes.php' },
|
||||||
|
'communications/index': { title: 'Index', ciViewPath: 'communications/index.php' },
|
||||||
|
'configuration/configuration_view': { title: 'Configuration View', ciViewPath: 'configuration/configuration_view.php' },
|
||||||
|
'contact': { title: 'Contact', ciViewPath: 'contact.php' },
|
||||||
|
'contact_process': { title: 'Contact Process', ciViewPath: 'contact_process.php' },
|
||||||
|
'discounts/apply_voucher': { title: 'Apply Voucher', ciViewPath: 'discounts/apply_voucher.php' },
|
||||||
|
'discounts/create': { title: 'Create', ciViewPath: 'discounts/create.php' },
|
||||||
|
'discounts/list': { title: 'List', ciViewPath: 'discounts/list.php' },
|
||||||
|
'emails/_wrap_layout': { title: ' Wrap Layout', ciViewPath: 'emails/_wrap_layout.php' },
|
||||||
|
'emails/admin_student_registered': { title: 'Admin Student Registered', ciViewPath: 'emails/admin_student_registered.php' },
|
||||||
|
'emails/below_sixty_performance': { title: 'Below Sixty Performance', ciViewPath: 'emails/below_sixty_performance.php' },
|
||||||
|
'emails/broadcast_wrapper': { title: 'Broadcast Wrapper', ciViewPath: 'emails/broadcast_wrapper.php' },
|
||||||
|
'emails/calendar_notification': { title: 'Calendar Notification', ciViewPath: 'emails/calendar_notification.php' },
|
||||||
|
'emails/custom_html': { title: 'Custom Html', ciViewPath: 'emails/custom_html.php' },
|
||||||
|
'emails/dismissal': { title: 'Dismissal', ciViewPath: 'emails/dismissal.php' },
|
||||||
|
'emails/event_broadcast': { title: 'Event Broadcast', ciViewPath: 'emails/event_broadcast.php' },
|
||||||
|
'emails/extra_charge_notice': { title: 'Extra Charge Notice', ciViewPath: 'emails/extra_charge_notice.php' },
|
||||||
|
'emails/final_warning': { title: 'Final Warning', ciViewPath: 'emails/final_warning.php' },
|
||||||
|
'emails/follow_up': { title: 'Follow Up', ciViewPath: 'emails/follow_up.php' },
|
||||||
|
'emails/parent_attendance_admin': { title: 'Parent Attendance Admin', ciViewPath: 'emails/parent_attendance_admin.php' },
|
||||||
|
'emails/parent_attendance_parent': { title: 'Parent Attendance Parent', ciViewPath: 'emails/parent_attendance_parent.php' },
|
||||||
|
'emails/parent_email_extractor': { title: 'Parent Email Extractor', ciViewPath: 'emails/parent_email_extractor.php' },
|
||||||
|
'emails/payment_receipt': { title: 'Payment Receipt', ciViewPath: 'emails/payment_receipt.php' },
|
||||||
|
'emails/reset_password': { title: 'Reset Password', ciViewPath: 'emails/reset_password.php' },
|
||||||
|
'emails/status_admission_review': { title: 'Status Admission Review', ciViewPath: 'emails/status_admission_review.php' },
|
||||||
|
'emails/status_denied': { title: 'Status Denied', ciViewPath: 'emails/status_denied.php' },
|
||||||
|
'emails/status_enrolled': { title: 'Status Enrolled', ciViewPath: 'emails/status_enrolled.php' },
|
||||||
|
'emails/status_not_enrolled': { title: 'Status Not Enrolled', ciViewPath: 'emails/status_not_enrolled.php' },
|
||||||
|
'emails/status_payment_pending': { title: 'Status Payment Pending', ciViewPath: 'emails/status_payment_pending.php' },
|
||||||
|
'emails/status_refund_pending': { title: 'Status Refund Pending', ciViewPath: 'emails/status_refund_pending.php' },
|
||||||
|
'emails/status_waitlist': { title: 'Status Waitlist', ciViewPath: 'emails/status_waitlist.php' },
|
||||||
|
'emails/status_withdraw_review': { title: 'Status Withdraw Review', ciViewPath: 'emails/status_withdraw_review.php' },
|
||||||
|
'emails/status_withdrawn': { title: 'Status Withdrawn', ciViewPath: 'emails/status_withdrawn.php' },
|
||||||
|
'emails/student_removed': { title: 'Student Removed', ciViewPath: 'emails/student_removed.php' },
|
||||||
|
'emails/support_new_account': { title: 'Support New Account', ciViewPath: 'emails/support_new_account.php' },
|
||||||
|
'emails/welcome_parent': { title: 'Welcome Parent', ciViewPath: 'emails/welcome_parent.php' },
|
||||||
|
'emails/welcome_staff': { title: 'Welcome Staff', ciViewPath: 'emails/welcome_staff.php' },
|
||||||
|
'emails/welcome_user': { title: 'Welcome User', ciViewPath: 'emails/welcome_user.php' },
|
||||||
|
'emails/whatsapp_group_invitation': { title: 'Whatsapp Group Invitation', ciViewPath: 'emails/whatsapp_group_invitation.php' },
|
||||||
|
'emails/whatsapp_invite': { title: 'Whatsapp Invite', ciViewPath: 'emails/whatsapp_invite.php' },
|
||||||
|
'enroll_withdraw/enrollment_withdrawal': { title: 'Enrollment Withdrawal', ciViewPath: 'enroll_withdraw/enrollment_withdrawal.php' },
|
||||||
|
'enroll_withdraw/new-students': { title: 'New-Students', ciViewPath: 'enroll_withdraw/new-students.php' },
|
||||||
|
'expenses/create': { title: 'Create', ciViewPath: 'expenses/create.php' },
|
||||||
|
'expenses/edit': { title: 'Edit', ciViewPath: 'expenses/edit.php' },
|
||||||
|
'expenses/index': { title: 'Index', ciViewPath: 'expenses/index.php' },
|
||||||
|
'facility': { title: 'Facility', ciViewPath: 'facility.php' },
|
||||||
|
'family/card': { title: 'Card', ciViewPath: 'family/card.php' },
|
||||||
|
'family/compose_email': { title: 'Compose Email', ciViewPath: 'family/compose_email.php' },
|
||||||
|
'family/index': { title: 'Index', ciViewPath: 'family/index.php' },
|
||||||
|
'flags/flags_management': { title: 'Flags Management', ciViewPath: 'flags/flags_management.php' },
|
||||||
|
'flags/incident_analysis': { title: 'Incident Analysis', ciViewPath: 'flags/incident_analysis.php' },
|
||||||
|
'flags/processed_flags': { title: 'Processed Flags', ciViewPath: 'flags/processed_flags.php' },
|
||||||
|
'footer': { title: 'Footer', ciViewPath: 'footer.php' },
|
||||||
|
'grading/below_sixty': { title: 'Below Sixty', ciViewPath: 'grading/below_sixty.php' },
|
||||||
|
'grading/below_sixty_email_editor': { title: 'Below Sixty Email Editor', ciViewPath: 'grading/below_sixty_email_editor.php' },
|
||||||
|
'grading/comments': { title: 'Comments', ciViewPath: 'grading/comments.php' },
|
||||||
|
'grading/final': { title: 'Final', ciViewPath: 'grading/final.php' },
|
||||||
|
'grading/grading_main': { title: 'Grading Main', ciViewPath: 'grading/grading_main.php' },
|
||||||
|
'grading/homework': { title: 'Homework', ciViewPath: 'grading/homework.php' },
|
||||||
|
'grading/homework_tracking': { title: 'Homework Tracking', ciViewPath: 'grading/homework_tracking.php' },
|
||||||
|
'grading/midterm': { title: 'Midterm', ciViewPath: 'grading/midterm.php' },
|
||||||
|
'grading/participation': { title: 'Participation', ciViewPath: 'grading/participation.php' },
|
||||||
|
'grading/placement': { title: 'Placement', ciViewPath: 'grading/placement.php' },
|
||||||
|
'grading/placement_batch': { title: 'Placement Batch', ciViewPath: 'grading/placement_batch.php' },
|
||||||
|
'grading/placement_index': { title: 'Placement Index', ciViewPath: 'grading/placement_index.php' },
|
||||||
|
'grading/project': { title: 'Project', ciViewPath: 'grading/project.php' },
|
||||||
|
'grading/quiz': { title: 'Quiz', ciViewPath: 'grading/quiz.php' },
|
||||||
|
'grading/schedule_meeting': { title: 'Schedule Meeting', ciViewPath: 'grading/schedule_meeting.php' },
|
||||||
|
'grading/test': { title: 'Test', ciViewPath: 'grading/test.php' },
|
||||||
|
'header': { title: 'Header', ciViewPath: 'header.php' },
|
||||||
|
'home': { title: 'Home', ciViewPath: 'home.php' },
|
||||||
|
'index': { title: 'Index', ciViewPath: 'index.php' },
|
||||||
|
'inventory/adjust_form': { title: 'Adjust Form', ciViewPath: 'inventory/adjust_form.php' },
|
||||||
|
'inventory/book/form': { title: 'Form', ciViewPath: 'inventory/book/form.php' },
|
||||||
|
'inventory/book/index': { title: 'Index', ciViewPath: 'inventory/book/index.php' },
|
||||||
|
'inventory/classroom/audit_form': { title: 'Audit Form', ciViewPath: 'inventory/classroom/audit_form.php' },
|
||||||
|
'inventory/classroom/form': { title: 'Form', ciViewPath: 'inventory/classroom/form.php' },
|
||||||
|
'inventory/classroom/index': { title: 'Index', ciViewPath: 'inventory/classroom/index.php' },
|
||||||
|
'inventory/kitchen/form': { title: 'Form', ciViewPath: 'inventory/kitchen/form.php' },
|
||||||
|
'inventory/kitchen/index': { title: 'Index', ciViewPath: 'inventory/kitchen/index.php' },
|
||||||
|
'inventory/movements/edit': { title: 'Edit', ciViewPath: 'inventory/movements/edit.php' },
|
||||||
|
'inventory/movements/form': { title: 'Form', ciViewPath: 'inventory/movements/form.php' },
|
||||||
|
'inventory/movements/index': { title: 'Index', ciViewPath: 'inventory/movements/index.php' },
|
||||||
|
'inventory/office/form': { title: 'Form', ciViewPath: 'inventory/office/form.php' },
|
||||||
|
'inventory/office/index': { title: 'Index', ciViewPath: 'inventory/office/index.php' },
|
||||||
|
'inventory/summary': { title: 'Summary', ciViewPath: 'inventory/summary.php' },
|
||||||
|
'inventory/teacher_distribute': { title: 'Teacher Distribute', ciViewPath: 'inventory/teacher_distribute.php' },
|
||||||
|
'invoice_payment/invoice_management': { title: 'Invoice Management', ciViewPath: 'invoice_payment/invoice_management.php' },
|
||||||
|
'invoice_payment/pdf_template': { title: 'Pdf Template', ciViewPath: 'invoice_payment/pdf_template.php' },
|
||||||
|
'landing_page/admin_dashboard': { title: 'Admin Dashboard', ciViewPath: 'landing_page/admin_dashboard.php' },
|
||||||
|
'landing_page/guest_dashboard': { title: 'Guest Dashboard', ciViewPath: 'landing_page/guest_dashboard.php' },
|
||||||
|
'landing_page/parent_dashboard': { title: 'Parent Dashboard', ciViewPath: 'landing_page/parent_dashboard.php' },
|
||||||
|
'landing_page/student_dashboard': { title: 'Student Dashboard', ciViewPath: 'landing_page/student_dashboard.php' },
|
||||||
|
'landing_page/teacher_dashboard': { title: 'Teacher Dashboard', ciViewPath: 'landing_page/teacher_dashboard.php' },
|
||||||
|
'nav_builder/edit': { title: 'Edit', ciViewPath: 'nav_builder/edit.php' },
|
||||||
|
'nav_builder/index': { title: 'Index', ciViewPath: 'nav_builder/index.php' },
|
||||||
|
'navbar': { title: 'Navbar', ciViewPath: 'navbar.php' },
|
||||||
|
'notifications/list_active': { title: 'List Active', ciViewPath: 'notifications/list_active.php' },
|
||||||
|
'notifications/list_deleted': { title: 'List Deleted', ciViewPath: 'notifications/list_deleted.php' },
|
||||||
|
'parent/add_second_parent': { title: 'Add Second Parent', ciViewPath: 'parent/add_second_parent.php' },
|
||||||
|
'parent/assignments': { title: 'Assignments', ciViewPath: 'parent/assignments.php' },
|
||||||
|
'parent/attendance': { title: 'Attendance', ciViewPath: 'parent/attendance.php' },
|
||||||
|
'parent/calendar': { title: 'Calendar', ciViewPath: 'parent/calendar.php' },
|
||||||
|
'parent/class_progress_list': { title: 'Class Progress List', ciViewPath: 'parent/class_progress_list.php' },
|
||||||
|
'parent/class_progress_view': { title: 'Class Progress View', ciViewPath: 'parent/class_progress_view.php' },
|
||||||
|
'parent/classes': { title: 'Classes', ciViewPath: 'parent/classes.php' },
|
||||||
|
'parent/contact': { title: 'Contact', ciViewPath: 'parent/contact.php' },
|
||||||
|
'parent/edit_emergency_contact': { title: 'Edit Emergency Contact', ciViewPath: 'parent/edit_emergency_contact.php' },
|
||||||
|
'parent/edit_student_modal': { title: 'Edit Student Modal', ciViewPath: 'parent/edit_student_modal.php' },
|
||||||
|
'parent/enroll_classes': { title: 'Enroll Classes', ciViewPath: 'parent/enroll_classes.php' },
|
||||||
|
'parent/enroll_failure': { title: 'Enroll Failure', ciViewPath: 'parent/enroll_failure.php' },
|
||||||
|
'parent/enroll_success': { title: 'Enroll Success', ciViewPath: 'parent/enroll_success.php' },
|
||||||
|
'parent/event_participation': { title: 'Event Participation', ciViewPath: 'parent/event_participation.php' },
|
||||||
|
'parent/invoice_payment': { title: 'Invoice Payment', ciViewPath: 'parent/invoice_payment.php' },
|
||||||
|
'parent/no_kids_registred': { title: 'No Kids Registred', ciViewPath: 'parent/no_kids_registred.php' },
|
||||||
|
'parent/parent_message': { title: 'Parent Message', ciViewPath: 'parent/parent_message.php' },
|
||||||
|
'parent/payment_success': { title: 'Payment Success', ciViewPath: 'parent/payment_success.php' },
|
||||||
|
'parent/payment_view': { title: 'Payment View', ciViewPath: 'parent/payment_view.php' },
|
||||||
|
'parent/register_student': { title: 'Register Student', ciViewPath: 'parent/register_student.php' },
|
||||||
|
'parent/report_attendance': { title: 'Report Attendance', ciViewPath: 'parent/report_attendance.php' },
|
||||||
|
'parent/report_cards': { title: 'Report Cards', ciViewPath: 'parent/report_cards.php' },
|
||||||
|
'parent/scores': { title: 'Scores', ciViewPath: 'parent/scores.php' },
|
||||||
|
'parent/success_message': { title: 'Success Message', ciViewPath: 'parent/success_message.php' },
|
||||||
|
'parent/withdraw_success': { title: 'Withdraw Success', ciViewPath: 'parent/withdraw_success.php' },
|
||||||
|
'payment/_list_modal': { title: ' List Modal', ciViewPath: 'payment/_list_modal.php' },
|
||||||
|
'payment/discount': { title: 'Discount', ciViewPath: 'payment/discount.php' },
|
||||||
|
'payment/extra_charges': { title: 'Extra Charges', ciViewPath: 'payment/extra_charges.php' },
|
||||||
|
'payment/financial_report': { title: 'Financial Report', ciViewPath: 'payment/financial_report.php' },
|
||||||
|
'payment/financial_report_summary': { title: 'Financial Report Summary', ciViewPath: 'payment/financial_report_summary.php' },
|
||||||
|
'payment/manual_pay': { title: 'Manual Pay', ciViewPath: 'payment/manual_pay.php' },
|
||||||
|
'payment/manual_payment': { title: 'Manual Payment', ciViewPath: 'payment/manual_payment.php' },
|
||||||
|
'payment/notification_management': { title: 'Notification Management', ciViewPath: 'payment/notification_management.php' },
|
||||||
|
'payment/payment_redirect': { title: 'Payment Redirect', ciViewPath: 'payment/payment_redirect.php' },
|
||||||
|
'payment/unpaid_parents': { title: 'Unpaid Parents', ciViewPath: 'payment/unpaid_parents.php' },
|
||||||
|
'payment_list': { title: 'Payment List', ciViewPath: 'payment_list.php' },
|
||||||
|
'policy/picture_policy': { title: 'Picture Policy', ciViewPath: 'policy/picture_policy.php' },
|
||||||
|
'policy/picture_policy_partial': { title: 'Picture Policy Partial', ciViewPath: 'policy/picture_policy_partial.php' },
|
||||||
|
'policy/school_policy': { title: 'School Policy', ciViewPath: 'policy/school_policy.php' },
|
||||||
|
'policy/school_policy_partial': { title: 'School Policy Partial', ciViewPath: 'policy/school_policy_partial.php' },
|
||||||
|
'preferences': { title: 'Preferences', ciViewPath: 'preferences.php' },
|
||||||
|
'print_requests/admin_index': { title: 'Admin Index', ciViewPath: 'print_requests/admin_index.php' },
|
||||||
|
'print_requests/teacher_index': { title: 'Teacher Index', ciViewPath: 'print_requests/teacher_index.php' },
|
||||||
|
'printables_reports/badge_form': { title: 'Badge Form', ciViewPath: 'printables_reports/badge_form.php' },
|
||||||
|
'printables_reports/report_card': { title: 'Report Card', ciViewPath: 'printables_reports/report_card.php' },
|
||||||
|
'printables_reports/sticker_form': { title: 'Sticker Form', ciViewPath: 'printables_reports/sticker_form.php' },
|
||||||
|
'profile': { title: 'Profile', ciViewPath: 'profile.php' },
|
||||||
|
'refunds/list': { title: 'List', ciViewPath: 'refunds/list.php' },
|
||||||
|
'reimbursements/create': { title: 'Create', ciViewPath: 'reimbursements/create.php' },
|
||||||
|
'reimbursements/edit': { title: 'Edit', ciViewPath: 'reimbursements/edit.php' },
|
||||||
|
'reimbursements/index': { title: 'Index', ciViewPath: 'reimbursements/index.php' },
|
||||||
|
'reimbursements/under_processing': { title: 'Under Processing', ciViewPath: 'reimbursements/under_processing.php' },
|
||||||
|
'rfid/rfid_coming_soon': { title: 'Rfid Coming Soon', ciViewPath: 'rfid/rfid_coming_soon.php' },
|
||||||
|
'rfid/rfid_log_view': { title: 'Rfid Log View', ciViewPath: 'rfid/rfid_log_view.php' },
|
||||||
|
'rolepermission/add_permission': { title: 'Add Permission', ciViewPath: 'rolepermission/add_permission.php' },
|
||||||
|
'rolepermission/add_role': { title: 'Add Role', ciViewPath: 'rolepermission/add_role.php' },
|
||||||
|
'rolepermission/assign_role': { title: 'Assign Role', ciViewPath: 'rolepermission/assign_role.php' },
|
||||||
|
'rolepermission/edit_permission': { title: 'Edit Permission', ciViewPath: 'rolepermission/edit_permission.php' },
|
||||||
|
'rolepermission/edit_role': { title: 'Edit Role', ciViewPath: 'rolepermission/edit_role.php' },
|
||||||
|
'rolepermission/edit_role_permissions': { title: 'Edit Role Permissions', ciViewPath: 'rolepermission/edit_role_permissions.php' },
|
||||||
|
'rolepermission/list_roles': { title: 'List Roles', ciViewPath: 'rolepermission/list_roles.php' },
|
||||||
|
'rolepermission/permissionList': { title: 'PermissionList', ciViewPath: 'rolepermission/permissionList.php' },
|
||||||
|
'score_analysis/score_prediction': { title: 'Score Prediction', ciViewPath: 'score_analysis/score_prediction.php' },
|
||||||
|
'slips/preview_list': { title: 'Preview List', ciViewPath: 'slips/preview_list.php' },
|
||||||
|
'slips/slip_pdf': { title: 'Slip Pdf', ciViewPath: 'slips/slip_pdf.php' },
|
||||||
|
'spinner': { title: 'Spinner', ciViewPath: 'spinner.php' },
|
||||||
|
'staff/create': { title: 'Create', ciViewPath: 'staff/create.php' },
|
||||||
|
'staff/edit': { title: 'Edit', ciViewPath: 'staff/edit.php' },
|
||||||
|
'staff/index': { title: 'Index', ciViewPath: 'staff/index.php' },
|
||||||
|
'student/score_card': { title: 'Score Card', ciViewPath: 'student/score_card.php' },
|
||||||
|
'student/score_card_list': { title: 'Score Card List', ciViewPath: 'student/score_card_list.php' },
|
||||||
|
'success': { title: 'Success', ciViewPath: 'success.php' },
|
||||||
|
'support': { title: 'Support', ciViewPath: 'support.php' },
|
||||||
|
'support_requests': { title: 'Support Requests', ciViewPath: 'support_requests.php' },
|
||||||
|
'swagger_ui': { title: 'Swagger Ui', ciViewPath: 'swagger_ui.php' },
|
||||||
|
'teacher/absence_vacation': { title: 'Absence Vacation', ciViewPath: 'teacher/absence_vacation.php' },
|
||||||
|
'teacher/add_final_exam': { title: 'Add Final Exam', ciViewPath: 'teacher/add_final_exam.php' },
|
||||||
|
'teacher/add_homework': { title: 'Add Homework', ciViewPath: 'teacher/add_homework.php' },
|
||||||
|
'teacher/add_midterm_exam': { title: 'Add Midterm Exam', ciViewPath: 'teacher/add_midterm_exam.php' },
|
||||||
|
'teacher/add_participation': { title: 'Add Participation', ciViewPath: 'teacher/add_participation.php' },
|
||||||
|
'teacher/add_project': { title: 'Add Project', ciViewPath: 'teacher/add_project.php' },
|
||||||
|
'teacher/add_quiz': { title: 'Add Quiz', ciViewPath: 'teacher/add_quiz.php' },
|
||||||
|
'teacher/add_quiz_column_form': { title: 'Add Quiz Column Form', ciViewPath: 'teacher/add_quiz_column_form.php' },
|
||||||
|
'teacher/calendar': { title: 'Calendar', ciViewPath: 'teacher/calendar.php' },
|
||||||
|
'teacher/class_progress_history': { title: 'Class Progress History', ciViewPath: 'teacher/class_progress_history.php' },
|
||||||
|
'teacher/class_progress_submit': { title: 'Class Progress Submit', ciViewPath: 'teacher/class_progress_submit.php' },
|
||||||
|
'teacher/class_progress_view': { title: 'Class Progress View', ciViewPath: 'teacher/class_progress_view.php' },
|
||||||
|
'teacher/class_view': { title: 'Class View', ciViewPath: 'teacher/class_view.php' },
|
||||||
|
'teacher/competition_scores/index': { title: 'Index', ciViewPath: 'teacher/competition_scores/index.php' },
|
||||||
|
'teacher/competition_scores/scores': { title: 'Scores', ciViewPath: 'teacher/competition_scores/scores.php' },
|
||||||
|
'teacher/drafts': { title: 'Drafts', ciViewPath: 'teacher/drafts.php' },
|
||||||
|
'teacher/exam_drafts': { title: 'Exam Drafts', ciViewPath: 'teacher/exam_drafts.php' },
|
||||||
|
'teacher/homework_list': { title: 'Homework List', ciViewPath: 'teacher/homework_list.php' },
|
||||||
|
'teacher/inbox': { title: 'Inbox', ciViewPath: 'teacher/inbox.php' },
|
||||||
|
'teacher/no_classes': { title: 'No Classes', ciViewPath: 'teacher/no_classes.php' },
|
||||||
|
'teacher/scores': { title: 'Scores', ciViewPath: 'teacher/scores.php' },
|
||||||
|
'teacher/select_semester': { title: 'Select Semester', ciViewPath: 'teacher/select_semester.php' },
|
||||||
|
'teacher/sent': { title: 'Sent', ciViewPath: 'teacher/sent.php' },
|
||||||
|
'teacher/showupdate_attendance': { title: 'Showupdate Attendance', ciViewPath: 'teacher/showupdate_attendance.php' },
|
||||||
|
'teacher/teacher_assignment': { title: 'Teacher Assignment', ciViewPath: 'teacher/teacher_assignment.php' },
|
||||||
|
'teacher/teacher_contactus': { title: 'Teacher Contactus', ciViewPath: 'teacher/teacher_contactus.php' },
|
||||||
|
'teacher/teacher_message': { title: 'Teacher Message', ciViewPath: 'teacher/teacher_message.php' },
|
||||||
|
'teacher/teacher_navbar': { title: 'Teacher Navbar', ciViewPath: 'teacher/teacher_navbar.php' },
|
||||||
|
'teacher/teacher_support': { title: 'Teacher Support', ciViewPath: 'teacher/teacher_support.php' },
|
||||||
|
'teacher/trash': { title: 'Trash', ciViewPath: 'teacher/trash.php' },
|
||||||
|
'team': { title: 'Team', ciViewPath: 'team.php' },
|
||||||
|
'testimonial': { title: 'Testimonial', ciViewPath: 'testimonial.php' },
|
||||||
|
'user/create': { title: 'Create', ciViewPath: 'user/create.php' },
|
||||||
|
'user/edit': { title: 'Edit', ciViewPath: 'user/edit.php' },
|
||||||
|
'user/forgot_password': { title: 'Forgot Password', ciViewPath: 'user/forgot_password.php' },
|
||||||
|
'user/login': { title: 'Login', ciViewPath: 'user/login.php' },
|
||||||
|
'user/login_activity': { title: 'Login Activity', ciViewPath: 'user/login_activity.php' },
|
||||||
|
'user/password_reset_confirmation': { title: 'Password Reset Confirmation', ciViewPath: 'user/password_reset_confirmation.php' },
|
||||||
|
'user/password_set_success': { title: 'Password Set Success', ciViewPath: 'user/password_set_success.php' },
|
||||||
|
'user/register': { title: 'Register', ciViewPath: 'user/register.php' },
|
||||||
|
'user/reset_password': { title: 'Reset Password', ciViewPath: 'user/reset_password.php' },
|
||||||
|
'user/set_authorized_user_password': { title: 'Set Authorized User Password', ciViewPath: 'user/set_authorized_user_password.php' },
|
||||||
|
'user/set_password': { title: 'Set Password', ciViewPath: 'user/set_password.php' },
|
||||||
|
'user/user_list': { title: 'User List', ciViewPath: 'user/user_list.php' },
|
||||||
|
'user/welcome_back': { title: 'Welcome Back', ciViewPath: 'user/welcome_back.php' },
|
||||||
|
'welcome_message': { title: 'Welcome Message', ciViewPath: 'welcome_message.php' },
|
||||||
|
'whatsapp/manage_links': { title: 'Manage Links', ciViewPath: 'whatsapp/manage_links.php' },
|
||||||
|
'whatsapp/parent_contacts': { title: 'Parent Contacts', ciViewPath: 'whatsapp/parent_contacts.php' },
|
||||||
|
'whatsapp/parent_contacts_by_class': { title: 'Parent Contacts By Class', ciViewPath: 'whatsapp/parent_contacts_by_class.php' },
|
||||||
|
'winners/competitions/index': { title: 'Index', ciViewPath: 'winners/competitions/index.php' },
|
||||||
|
'winners/competitions/show': { title: 'Show', ciViewPath: 'winners/competitions/show.php' },
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/** Map legacy CodeIgniter-style paths (as stored on roles / nav items) to this SPA. */
|
||||||
|
|
||||||
|
export function spaPathFromCiUrl(url: string | null | undefined): string | null {
|
||||||
|
if (url == null) return null
|
||||||
|
const u = url.trim()
|
||||||
|
if (u === '' || u === '#') return '/app/home'
|
||||||
|
if (/^https?:\/\//i.test(u)) return null
|
||||||
|
|
||||||
|
const path = u.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||||
|
if (!path) return '/app/home'
|
||||||
|
|
||||||
|
const slug = path
|
||||||
|
.split('/')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => s.toLowerCase())
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const shortcuts: Record<string, string> = {
|
||||||
|
'landing_page/guest_dashboard': '/app/home',
|
||||||
|
'teacher/dashboard': '/app/teacher/dashboard',
|
||||||
|
'administrator/dashboard': '/app/administrator/dashboard',
|
||||||
|
'administrator/administratordashboard': '/app/admin/dashboard',
|
||||||
|
'parent/parent_dashboard': '/app/parent/home',
|
||||||
|
parent_dashboard: '/app/parent/home',
|
||||||
|
'parent/attendance-reports': '/app/parent/attendance-reports',
|
||||||
|
'parent/authorized-users': '/app/parent/authorized-users',
|
||||||
|
'parent/update-profile': '/app/parent/update-profile',
|
||||||
|
'parent/add_second_parent': '/app/parent/add-second-parent',
|
||||||
|
'parent/add-second-parent': '/app/parent/add-second-parent',
|
||||||
|
'parent/enroll_classes': '/app/parent/enroll-classes',
|
||||||
|
'parent/enroll-classes': '/app/parent/enroll-classes',
|
||||||
|
'parent/enroll_success': '/app/parent/enroll/success',
|
||||||
|
'parent/enroll_failure': '/app/parent/enroll/failure',
|
||||||
|
'parent/payment': '/app/parent/payment',
|
||||||
|
'parent/invoice_payment': '/app/parent/invoice-payment',
|
||||||
|
'parent/invoice-payment': '/app/parent/invoice-payment',
|
||||||
|
'parent/calendar': '/app/parent/calendar',
|
||||||
|
'parent/attendance': '/app/parent/attendance',
|
||||||
|
'parent/report-attendance': '/app/parent/report-attendance',
|
||||||
|
'parent/report_cards': '/app/parent/report-cards',
|
||||||
|
'parent/report-cards': '/app/parent/report-cards',
|
||||||
|
'parent/progress': '/app/parent/progress',
|
||||||
|
'parent/events': '/app/parent/events',
|
||||||
|
'parent/contact': '/app/parent/contact',
|
||||||
|
'parent/register_student': '/app/parent/register-student',
|
||||||
|
'parent/register-student': '/app/parent/register-student',
|
||||||
|
'parent/scores': '/app/parent/scores',
|
||||||
|
'parent/assignments': '/app/parent/assignments',
|
||||||
|
'parent/classes': '/app/parent/classes',
|
||||||
|
'parent/viewsecondparent': '/app/parent/add-second-parent',
|
||||||
|
'parent/child_register': '/app/parent/register-student',
|
||||||
|
'parent/edit-emergency-contact': '/app/parent/edit-emergency-contact',
|
||||||
|
'parent/emergency-contacts': '/app/parent/emergency-contacts',
|
||||||
|
'parent/edit_all_students': '/app/parent/edit-all-students',
|
||||||
|
'parent/edit-all-students': '/app/parent/edit-all-students',
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortcuts[slug] ?? `/app/${slug}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExternalNavUrl(url: string | null | undefined): boolean {
|
||||||
|
if (!url) return false
|
||||||
|
return /^https?:\/\//i.test(url.trim())
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import './styles/ci-registration.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { SwaggerDocs } from './SwaggerDocs'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'alrahma_docs_jwt'
|
||||||
|
|
||||||
|
export function AdminDocs() {
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [saved, setSaved] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const t = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (t) {
|
||||||
|
setSaved(t)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const authHeader = saved ? `Bearer ${saved}` : null
|
||||||
|
|
||||||
|
const saveToken = useCallback(() => {
|
||||||
|
const t = token.trim()
|
||||||
|
if (!t) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
setSaved(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, t)
|
||||||
|
setSaved(t)
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-docs-shell">
|
||||||
|
<header className="docs-toolbar">
|
||||||
|
<Link to="/docs">Public docs</Link>
|
||||||
|
<label>
|
||||||
|
JWT (admin){' '}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={token}
|
||||||
|
placeholder={saved ? '•••••••• (saved)' : ''}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={saveToken}>
|
||||||
|
Save token
|
||||||
|
</button>
|
||||||
|
{saved ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
setSaved(null)
|
||||||
|
setToken('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
<SwaggerDocs bootstrapPath="/api/docs" authorization={authHeader} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { fetchDashboardRoute } from '../api/session'
|
||||||
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
|
||||||
|
export function AppHome() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [route, setRoute] = useState<string | null>(null)
|
||||||
|
const [roles, setRoles] = useState<string[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchDashboardRoute()
|
||||||
|
const d = res.data?.dashboard
|
||||||
|
if (!cancelled && d) {
|
||||||
|
setRoute(d.route)
|
||||||
|
setRoles(d.roles ?? [])
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unable to load dashboard.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const roleKeys = user?.roles ? Object.keys(user.roles).filter((k) => user.roles[k]) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="h4 mb-3">Welcome</h2>
|
||||||
|
<p className="lead mb-4">
|
||||||
|
Signed in as <strong>{user?.name}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="row g-3 mb-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card border-0 shadow-sm h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h6 text-muted">Roles (from login)</h3>
|
||||||
|
<ul className="mb-0">
|
||||||
|
{roleKeys.length ? (
|
||||||
|
roleKeys.map((r) => <li key={r}>{r}</li>)
|
||||||
|
) : (
|
||||||
|
<li className="text-muted">None reported</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card border-0 shadow-sm h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h6 text-muted">Dashboard route (API)</h3>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-danger small mb-0">{error}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-1">
|
||||||
|
<code>{route ?? '—'}</code>
|
||||||
|
</p>
|
||||||
|
<p className="small text-muted mb-0">
|
||||||
|
Resolved roles: {roles.length ? roles.join(', ') : '—'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded p-3 bg-white">
|
||||||
|
<h3 className="h6">Shortcuts</h3>
|
||||||
|
<ul className="mb-0">
|
||||||
|
<li>
|
||||||
|
<Link to="/app/parent/home">Parent — Dashboard</Link> (parity with
|
||||||
|
CodeIgniter <code>parent/parent_dashboard</code>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/app/parent/attendance">Parent — Attendance</Link> (parity
|
||||||
|
with CodeIgniter <code>Views/parent/attendance.php</code>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/app/parent/home">Parent — All pages</Link> (navigation for
|
||||||
|
every view under <code>Views/parent/</code>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/docs">Public API docs (Swagger)</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/app/browse">All CodeIgniter views (sitemap)</Link> — open any{' '}
|
||||||
|
<code>app/Views</code> screen as an <code>/app/…</code> parity route
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import { getCiMeta } from '../lib/ciRouteLookup'
|
||||||
|
|
||||||
|
/** Fallback for CI-style paths not yet ported to dedicated React pages. */
|
||||||
|
export function CiPlaceholderPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const star = params['*'] ?? ''
|
||||||
|
const segments = star.split('/').filter(Boolean)
|
||||||
|
const meta = getCiMeta(star)
|
||||||
|
|
||||||
|
const title = meta?.title ?? 'Section placeholder'
|
||||||
|
const viewsPath = meta?.ciViewPath ?? (star ? `…/${star}` : '…')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol className="breadcrumb">
|
||||||
|
<li className="breadcrumb-item">
|
||||||
|
<Link to="/app/home">Home</Link>
|
||||||
|
</li>
|
||||||
|
{segments.map((s, i) => (
|
||||||
|
<li
|
||||||
|
key={`${s}-${i}`}
|
||||||
|
className={`breadcrumb-item ${i === segments.length - 1 ? 'active' : ''}`}
|
||||||
|
aria-current={i === segments.length - 1 ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h2 className="h4 mb-3">{title}</h2>
|
||||||
|
<p className="text-muted mb-2">
|
||||||
|
CodeIgniter view:{' '}
|
||||||
|
<code>
|
||||||
|
app/Views/{viewsPath}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted small mb-3">
|
||||||
|
Implement this screen using the matching routes in <code>app_laravel/routes/api.php</code>{' '}
|
||||||
|
(JWT <code>Authorization: Bearer</code>). See also <Link to="/docs">API docs</Link>.
|
||||||
|
</p>
|
||||||
|
<p className="small mb-3">
|
||||||
|
SPA path: <code>/app/{star || '…'}</code>
|
||||||
|
</p>
|
||||||
|
<p className="small mb-0">
|
||||||
|
<Link to="/app/browse">Browse all CI view routes</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/app/home">App home</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ciRouteMeta } from '../lib/ciRouteMeta.generated'
|
||||||
|
import { ciRegistryKeyToAppPath } from '../lib/ciRouteLookup'
|
||||||
|
|
||||||
|
/** Browse every CodeIgniter view file mapped under `/app/…` (parity placeholders unless a dedicated route exists). */
|
||||||
|
export function CiSitemapPage() {
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const g = new Map<string, { key: string; title: string }[]>()
|
||||||
|
for (const key of Object.keys(ciRouteMeta).sort()) {
|
||||||
|
const top = key.includes('/') ? key.slice(0, key.indexOf('/')) : key
|
||||||
|
const title = ciRouteMeta[key]?.title ?? key
|
||||||
|
const list = g.get(top) ?? []
|
||||||
|
list.push({ key, title })
|
||||||
|
g.set(top, list)
|
||||||
|
}
|
||||||
|
return [...g.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol className="breadcrumb">
|
||||||
|
<li className="breadcrumb-item">
|
||||||
|
<Link to="/app/home">Home</Link>
|
||||||
|
</li>
|
||||||
|
<li className="breadcrumb-item active" aria-current="page">
|
||||||
|
CI views
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h2 className="h4 mb-2">CodeIgniter views → SPA routes</h2>
|
||||||
|
<p className="text-muted small mb-4">
|
||||||
|
Generated from <code>app_codeigniter/app/Views/**/*.php</code>. Each link opens the parity
|
||||||
|
route under <code>/app/…</code>; unimplemented screens use the section placeholder with the
|
||||||
|
matching view path. Regenerate: <code>npm run gen:ci-meta</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="row g-4">
|
||||||
|
{grouped.map(([section, items]) => (
|
||||||
|
<div key={section} className="col-12 col-md-6 col-xl-4">
|
||||||
|
<div className="border rounded bg-white shadow-sm">
|
||||||
|
<div className="px-3 py-2 border-bottom small fw-semibold text-muted">{section}</div>
|
||||||
|
<ul className="list-unstyled mb-0 px-2 py-2" style={{ maxHeight: 280, overflow: 'auto' }}>
|
||||||
|
{items.map(({ key, title }) => (
|
||||||
|
<li key={key} className="small py-1">
|
||||||
|
<Link to={ciRegistryKeyToAppPath(key)}>{title}</Link>
|
||||||
|
<div className="text-muted" style={{ fontSize: '0.75rem' }}>
|
||||||
|
<code>{key.replace(/\//g, ' / ')}</code>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
/** Mirrors CodeIgniter `Views/user/forgot_password.php` shell; reset flow remains on the Laravel site. */
|
||||||
|
export function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<div className="ci-auth-bg flex-grow-1 py-4">
|
||||||
|
<div className="registration-form ci-parity container mt-5 mb-5 text-center">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<Link to="/">
|
||||||
|
<img
|
||||||
|
src="/alrahma_logo.png"
|
||||||
|
alt=""
|
||||||
|
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||||
|
onError={(ev) => {
|
||||||
|
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-center text-success mb-4" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||||
|
Reset Your Password
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mx-auto mb-4 text-muted" style={{ maxWidth: 560 }}>
|
||||||
|
<p>Password reset requests are handled through the school's main website.</p>
|
||||||
|
<p className="mb-0">
|
||||||
|
If this SPA is only the API client, open the legacy site or contact the office for a
|
||||||
|
reset link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-grid gap-2" style={{ maxWidth: 400, margin: '0 auto' }}>
|
||||||
|
<Link className="btn btn-success item" to="/login">
|
||||||
|
Back to Sign in
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-secondary item" to="/register">
|
||||||
|
Register now
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
|
||||||
|
/** Public landing — structure inspired by CodeIgniter `Views/landing_page/guest_dashboard.php` hero. */
|
||||||
|
export function HomePage() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<section className="hero-section position-relative overflow-hidden bg-white">
|
||||||
|
<div className="welcome-message text-center py-5 px-3">
|
||||||
|
<h1 className="display-5 fw-semibold text-success mb-2">
|
||||||
|
Welcome to Al Rahma Sunday School
|
||||||
|
</h1>
|
||||||
|
<h2 className="h4 text-secondary mb-3">
|
||||||
|
{isAuthenticated ? 'Welcome back' : 'Welcome, Guest!'}
|
||||||
|
</h2>
|
||||||
|
<p className="lead text-muted mx-auto mb-4" style={{ maxWidth: 640 }}>
|
||||||
|
Use the portal to manage enrollment, attendance, grades, and family information — the
|
||||||
|
same workflows as the CodeIgniter site, backed by the Laravel API.
|
||||||
|
</p>
|
||||||
|
<div className="d-flex flex-wrap justify-content-center gap-2">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Link className="btn btn-success btn-lg px-4" to="/app/home">
|
||||||
|
Go to dashboard
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link className="btn btn-success btn-lg px-4" to="/login">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-success btn-lg px-4" to="/register">
|
||||||
|
Registration Form
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link className="btn btn-outline-secondary btn-lg" to="/docs">
|
||||||
|
API documentation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-5 border-top bg-light">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row g-4 justify-content-center">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card border-0 shadow-sm h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h6 text-success">
|
||||||
|
<i className="bi bi-house-door me-2" aria-hidden />
|
||||||
|
Families
|
||||||
|
</h3>
|
||||||
|
<p className="small text-muted mb-0">
|
||||||
|
Parents sign in after registration is activated by email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card border-0 shadow-sm h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h6 text-success">
|
||||||
|
<i className="bi bi-shield-lock me-2" aria-hidden />
|
||||||
|
Secure portal
|
||||||
|
</h3>
|
||||||
|
<p className="small text-muted mb-0">
|
||||||
|
Sessions use JWT from the Laravel API (<code>/api/v1/auth/login</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { fetchDashboardRoute } from '../api/session'
|
||||||
|
import { useAuth } from '../auth/AuthProvider'
|
||||||
|
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
|
||||||
|
|
||||||
|
/** Matches CodeIgniter `Views/user/login.php` (JWT via `/api/v1/auth/login`). */
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const locState = location.state as { from?: string; registered?: boolean; message?: string } | null
|
||||||
|
|
||||||
|
const from = locState?.from ?? '/app/home'
|
||||||
|
const flashSuccess =
|
||||||
|
locState?.registered === true ? locState?.message ?? 'Registration submitted.' : null
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await login(email, password)
|
||||||
|
if (result.ok === false) {
|
||||||
|
setError(result.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = from.startsWith('/app') ? from : '/app/home'
|
||||||
|
try {
|
||||||
|
const dash = await fetchDashboardRoute()
|
||||||
|
const route = dash.data?.dashboard?.route
|
||||||
|
const mapped = spaPathFromCiUrl(route)
|
||||||
|
if (mapped) target = mapped
|
||||||
|
} catch {
|
||||||
|
/* keep default */
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(target, { replace: true })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ci-auth-bg flex-grow-1 py-4">
|
||||||
|
<div className="registration-form ci-parity container mt-5 mb-5">
|
||||||
|
<form id="loginForm" onSubmit={onSubmit}>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<Link to="/">
|
||||||
|
<img
|
||||||
|
src="/alrahma_logo.png"
|
||||||
|
alt=""
|
||||||
|
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||||
|
onError={(ev) => {
|
||||||
|
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className="text-center text-success mb-4"
|
||||||
|
style={{ fontFamily: 'Arial, sans-serif' }}
|
||||||
|
>
|
||||||
|
Login to Your Account
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{flashSuccess ? (
|
||||||
|
<div className="alert alert-success text-center" role="alert">
|
||||||
|
{flashSuccess}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label visually-hidden" htmlFor="email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control item"
|
||||||
|
id="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
maxLength={50}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 position-relative">
|
||||||
|
<label className="form-label visually-hidden" htmlFor="password">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className="form-control item pe-5"
|
||||||
|
id="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
maxLength={30}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link position-absolute top-50 end-0 translate-middle-y me-2 px-2 py-0 border-0"
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
>
|
||||||
|
<i className={`bi ${showPassword ? 'bi-eye-slash' : 'bi-eye'}`} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-danger mt-3 text-center" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-3 d-grid">
|
||||||
|
<button type="submit" className="btn btn-success item" disabled={busy}>
|
||||||
|
{busy ? 'Signing in…' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="forgot-password text-center mt-3">
|
||||||
|
<Link to="/forgot-password">Forgot Password?</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center small mt-4 mb-0">
|
||||||
|
Need an account? <Link to="/register">Register now</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
fetchRegisterCaptcha,
|
||||||
|
fetchSchoolPolicyHtml,
|
||||||
|
submitFullRegistration,
|
||||||
|
type FullRegisterPayload,
|
||||||
|
} from '../api/registerFull'
|
||||||
|
import { NE_STATES } from '../data/neStates'
|
||||||
|
|
||||||
|
function formatUsPhone(input: string): string {
|
||||||
|
const raw = input.replace(/\D/g, '').slice(0, 10)
|
||||||
|
if (raw.length <= 3) return raw
|
||||||
|
if (raw.length <= 6) return `${raw.slice(0, 3)}-${raw.slice(3)}`
|
||||||
|
return `${raw.slice(0, 3)}-${raw.slice(3, 6)}-${raw.slice(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches CodeIgniter `Views/user/register.php` + Laravel `POST /api/v1/auth/register` (captcha flow). */
|
||||||
|
export function RegisterPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [captchaChallenge, setCaptchaChallenge] = useState('')
|
||||||
|
const [captchaLoading, setCaptchaLoading] = useState(true)
|
||||||
|
|
||||||
|
const [firstname, setFirstname] = useState('')
|
||||||
|
const [lastname, setLastname] = useState('')
|
||||||
|
const [gender, setGender] = useState('')
|
||||||
|
const [cellphone, setCellphone] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [confirmEmail, setConfirmEmail] = useState('')
|
||||||
|
const [addressStreet, setAddressStreet] = useState('')
|
||||||
|
const [apt, setApt] = useState('')
|
||||||
|
const [city, setCity] = useState('')
|
||||||
|
const [state, setState] = useState('')
|
||||||
|
const [zip, setZip] = useState('')
|
||||||
|
|
||||||
|
const [isParent, setIsParent] = useState(false)
|
||||||
|
const [noSecondParentInfo, setNoSecondParentInfo] = useState(false)
|
||||||
|
const [secondFirstname, setSecondFirstname] = useState('')
|
||||||
|
const [secondLastname, setSecondLastname] = useState('')
|
||||||
|
const [secondGender, setSecondGender] = useState('')
|
||||||
|
const [secondEmail, setSecondEmail] = useState('')
|
||||||
|
const [secondCellphone, setSecondCellphone] = useState('')
|
||||||
|
|
||||||
|
const [acceptPolicy, setAcceptPolicy] = useState(false)
|
||||||
|
const [captchaAnswer, setCaptchaAnswer] = useState('')
|
||||||
|
|
||||||
|
const [policyHtml, setPolicyHtml] = useState<string | null>(null)
|
||||||
|
const [policyLoading, setPolicyLoading] = useState(false)
|
||||||
|
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({})
|
||||||
|
|
||||||
|
async function loadCaptcha() {
|
||||||
|
setCaptchaLoading(true)
|
||||||
|
try {
|
||||||
|
const { challenge } = await fetchRegisterCaptcha()
|
||||||
|
setCaptchaChallenge(challenge)
|
||||||
|
setCaptchaAnswer('')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Could not load CAPTCHA.')
|
||||||
|
} finally {
|
||||||
|
setCaptchaLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCaptcha()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function openPolicyModal() {
|
||||||
|
if (!policyHtml) {
|
||||||
|
setPolicyLoading(true)
|
||||||
|
try {
|
||||||
|
const html = await fetchSchoolPolicyHtml()
|
||||||
|
setPolicyHtml(html)
|
||||||
|
} catch {
|
||||||
|
setPolicyHtml('<p>Unable to load policy.</p>')
|
||||||
|
} finally {
|
||||||
|
setPolicyLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = document.getElementById('policyModal')
|
||||||
|
const B = (
|
||||||
|
window as unknown as {
|
||||||
|
bootstrap?: { Modal: { getOrCreateInstance: (n: HTMLElement) => { show: () => void } } }
|
||||||
|
}
|
||||||
|
).bootstrap
|
||||||
|
if (el && B?.Modal) {
|
||||||
|
B.Modal.getOrCreateInstance(el).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldErr(key: string): string | undefined {
|
||||||
|
return fieldErrors[key]?.[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setFieldErrors({})
|
||||||
|
|
||||||
|
if (email.trim() !== confirmEmail.trim()) {
|
||||||
|
setError('Email and confirm email must match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!acceptPolicy) {
|
||||||
|
setError('You must accept the school policies.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const zipOk = /^\d{5}$/.test(zip.trim())
|
||||||
|
if (!zipOk) {
|
||||||
|
setError('Zip must be exactly 5 digits.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const phoneDigits = cellphone.replace(/\D/g, '')
|
||||||
|
if (phoneDigits.length !== 10) {
|
||||||
|
setError('Please enter a valid 10-digit cell phone number.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isParent && !noSecondParentInfo) {
|
||||||
|
const sd = secondCellphone.replace(/\D/g, '')
|
||||||
|
if (sd.length !== 10) {
|
||||||
|
setError('Second parent phone must be a valid 10-digit number.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!secondFirstname.trim() || !secondLastname.trim() || !secondGender || !secondEmail.trim()) {
|
||||||
|
setError('Please complete second parent / guardian information.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: FullRegisterPayload = {
|
||||||
|
firstname,
|
||||||
|
lastname,
|
||||||
|
gender,
|
||||||
|
cellphone,
|
||||||
|
email,
|
||||||
|
confirm_email: confirmEmail,
|
||||||
|
address_street: addressStreet,
|
||||||
|
apt,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
zip: zip.trim(),
|
||||||
|
is_parent: isParent,
|
||||||
|
no_second_parent_info: noSecondParentInfo,
|
||||||
|
accept_school_policy: acceptPolicy,
|
||||||
|
captcha: captchaAnswer,
|
||||||
|
second_firstname: secondFirstname,
|
||||||
|
second_lastname: secondLastname,
|
||||||
|
second_gender: secondGender,
|
||||||
|
second_email: secondEmail,
|
||||||
|
second_cellphone: secondCellphone,
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await submitFullRegistration(payload)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.message)
|
||||||
|
if ('errors' in result && result.errors) setFieldErrors(result.errors)
|
||||||
|
void loadCaptcha()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate('/login', {
|
||||||
|
replace: true,
|
||||||
|
state: {
|
||||||
|
registered: true,
|
||||||
|
message:
|
||||||
|
'Registration submitted. Please check your email to activate your account when instructed.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSecondSection = isParent
|
||||||
|
const showSecondFields = isParent && !noSecondParentInfo
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ci-auth-bg flex-grow-1 py-4">
|
||||||
|
<div className="registration-form ci-parity container mt-3 mb-5">
|
||||||
|
<form id="registrationForm" onSubmit={onSubmit}>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<Link to="/">
|
||||||
|
<img
|
||||||
|
src="/alrahma_logo.png"
|
||||||
|
alt="Al Rahma Sunday School"
|
||||||
|
style={{ width: 180, height: 120, objectFit: 'contain' }}
|
||||||
|
onError={(ev) => {
|
||||||
|
;(ev.target as HTMLImageElement).style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className="text-center text-success mb-3"
|
||||||
|
style={{ fontFamily: 'Arial, sans-serif' }}
|
||||||
|
>
|
||||||
|
Registration Form
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="alert alert-info" role="alert">
|
||||||
|
- This account is for parents/guardians to manage students. If you already registered
|
||||||
|
in a prior year, please reuse your existing account.
|
||||||
|
<br />- If you cannot access your old email, reset your password.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-secondary mt-3 mb-2 small">
|
||||||
|
(All fields with * are required)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control item ${fieldErr('firstname') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="First Name*"
|
||||||
|
maxLength={50}
|
||||||
|
required
|
||||||
|
value={firstname}
|
||||||
|
onChange={(e) => setFirstname(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('firstname') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('firstname')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control item ${fieldErr('lastname') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="Last Name*"
|
||||||
|
maxLength={50}
|
||||||
|
required
|
||||||
|
value={lastname}
|
||||||
|
onChange={(e) => setLastname(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('lastname') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('lastname')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<select
|
||||||
|
className={`form-select item ${fieldErr('gender') ? 'is-invalid' : ''}`}
|
||||||
|
required
|
||||||
|
value={gender}
|
||||||
|
onChange={(e) => setGender(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select Gender*
|
||||||
|
</option>
|
||||||
|
<option value="Male">Male</option>
|
||||||
|
<option value="Female">Female</option>
|
||||||
|
</select>
|
||||||
|
{fieldErr('gender') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('gender')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className={`form-control item ${fieldErr('cellphone') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="Phone - Cell*"
|
||||||
|
maxLength={12}
|
||||||
|
required
|
||||||
|
title="10-digit US phone"
|
||||||
|
value={cellphone}
|
||||||
|
onChange={(e) => setCellphone(formatUsPhone(e.target.value))}
|
||||||
|
/>
|
||||||
|
{fieldErr('cellphone') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('cellphone')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className={`form-control item ${fieldErr('email') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="Email*"
|
||||||
|
maxLength={50}
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('email') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('email')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className={`form-control item ${fieldErr('confirm_email') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="Confirm Email*"
|
||||||
|
maxLength={50}
|
||||||
|
required
|
||||||
|
value={confirmEmail}
|
||||||
|
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{fieldErr('confirm_email') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('confirm_email')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control item ${fieldErr('address_street') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="Address - Street*"
|
||||||
|
maxLength={50}
|
||||||
|
required
|
||||||
|
value={addressStreet}
|
||||||
|
onChange={(e) => setAddressStreet(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('address_street') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('address_street')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control item"
|
||||||
|
placeholder="Apt"
|
||||||
|
maxLength={15}
|
||||||
|
value={apt}
|
||||||
|
onChange={(e) => setApt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control item ${fieldErr('city') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="City*"
|
||||||
|
maxLength={30}
|
||||||
|
required
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('city') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('city')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<select
|
||||||
|
className={`form-select item ${fieldErr('state') ? 'is-invalid' : ''}`}
|
||||||
|
required
|
||||||
|
value={state}
|
||||||
|
onChange={(e) => setState(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select State*
|
||||||
|
</option>
|
||||||
|
{NE_STATES.map((s) => (
|
||||||
|
<option key={s.abbr} value={s.abbr}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{fieldErr('state') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('state')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control item ${fieldErr('zip') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="Zip*"
|
||||||
|
maxLength={5}
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="\d{5}"
|
||||||
|
required
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value.replace(/\D/g, '').slice(0, 5))}
|
||||||
|
/>
|
||||||
|
{fieldErr('zip') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('zip')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-check mb-3">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="is_parent"
|
||||||
|
checked={isParent}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIsParent(e.target.checked)
|
||||||
|
if (!e.target.checked) {
|
||||||
|
setNoSecondParentInfo(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="is_parent">
|
||||||
|
Check this box if you are a Parent/Guardian
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSecondSection ? (
|
||||||
|
<div
|
||||||
|
className="mb-4 p-3 rounded"
|
||||||
|
style={{ border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
<h6 className="mb-3">Second Parent/Guardian Information</h6>
|
||||||
|
|
||||||
|
<div className="form-check form-switch mb-3">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="no_second_parent_info"
|
||||||
|
checked={noSecondParentInfo}
|
||||||
|
onChange={(e) => setNoSecondParentInfo(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="no_second_parent_info">
|
||||||
|
Check this box if no second parent info is available
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSecondFields ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control item"
|
||||||
|
placeholder="First Name*"
|
||||||
|
maxLength={50}
|
||||||
|
value={secondFirstname}
|
||||||
|
onChange={(e) => setSecondFirstname(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('second_firstname') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('second_firstname')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control item"
|
||||||
|
placeholder="Last Name*"
|
||||||
|
maxLength={50}
|
||||||
|
value={secondLastname}
|
||||||
|
onChange={(e) => setSecondLastname(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('second_lastname') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('second_lastname')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<select
|
||||||
|
className="form-select item"
|
||||||
|
required
|
||||||
|
value={secondGender}
|
||||||
|
onChange={(e) => setSecondGender(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select Gender*
|
||||||
|
</option>
|
||||||
|
<option value="Male">Male</option>
|
||||||
|
<option value="Female">Female</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control item"
|
||||||
|
placeholder="Email*"
|
||||||
|
maxLength={50}
|
||||||
|
value={secondEmail}
|
||||||
|
onChange={(e) => setSecondEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('second_email') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('second_email')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className="form-control item"
|
||||||
|
placeholder="Phone - Cell*"
|
||||||
|
maxLength={12}
|
||||||
|
value={secondCellphone}
|
||||||
|
onChange={(e) => setSecondCellphone(formatUsPhone(e.target.value))}
|
||||||
|
/>
|
||||||
|
{fieldErr('second_cellphone') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('second_cellphone')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="form-check mb-3">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="accept_school_policy"
|
||||||
|
checked={acceptPolicy}
|
||||||
|
required
|
||||||
|
onChange={(e) => setAcceptPolicy(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label" htmlFor="accept_school_policy">
|
||||||
|
I have read and I accept all school policies{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link p-0 small text-success align-baseline"
|
||||||
|
onClick={() => void openPolicyModal()}
|
||||||
|
>
|
||||||
|
(Read school policies here)
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-center border p-2 bg-light item">
|
||||||
|
{captchaLoading ? (
|
||||||
|
<span className="text-muted">Loading CAPTCHA…</span>
|
||||||
|
) : (
|
||||||
|
<span className="user-select-all">{captchaChallenge}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link btn-sm px-0"
|
||||||
|
onClick={() => void loadCaptcha()}
|
||||||
|
>
|
||||||
|
Refresh CAPTCHA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control item ${fieldErr('captcha') ? 'is-invalid' : ''}`}
|
||||||
|
placeholder="Enter the text above*"
|
||||||
|
maxLength={10}
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
value={captchaAnswer}
|
||||||
|
onChange={(e) => setCaptchaAnswer(e.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErr('captcha') ? (
|
||||||
|
<div className="text-danger small">{fieldErr('captcha')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 d-grid gap-2">
|
||||||
|
<button type="submit" className="btn btn-success item" disabled={busy || captchaLoading}>
|
||||||
|
{busy ? 'Submitting…' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
<Link to="/" className="btn btn-secondary item text-center">
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="modal fade"
|
||||||
|
id="policyModal"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-labelledby="policyModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title text-success" id="policyModalLabel">
|
||||||
|
School Policies
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{policyLoading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="policy-html"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: policyHtml ?? '<p>Click “Read school policies” to load.</p>',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import SwaggerUI from 'swagger-ui-react'
|
||||||
|
import 'swagger-ui-react/swagger-ui.css'
|
||||||
|
import type { DocsBootstrap } from '../types/docsBootstrap'
|
||||||
|
import { apiUrl } from '../lib/apiOrigin'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bootstrapPath: string
|
||||||
|
authorization?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwaggerDocs({ bootstrapPath, authorization }: Props) {
|
||||||
|
const [bootstrap, setBootstrap] = useState<DocsBootstrap | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = { Accept: 'application/json' }
|
||||||
|
if (authorization) {
|
||||||
|
headers.Authorization = authorization
|
||||||
|
}
|
||||||
|
const res = await fetch(apiUrl(bootstrapPath), { headers })
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof body?.message === 'string'
|
||||||
|
? body.message
|
||||||
|
: `HTTP ${res.status}`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setBootstrap(body as DocsBootstrap)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setBootstrap(null)
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load docs config')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [bootstrapPath, authorization])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bootstrap?.title) {
|
||||||
|
document.title = bootstrap.title
|
||||||
|
}
|
||||||
|
}, [bootstrap?.title])
|
||||||
|
|
||||||
|
const specUrl = useMemo(() => {
|
||||||
|
if (!bootstrap?.openapi_url) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return apiUrl(bootstrap.openapi_url)
|
||||||
|
}, [bootstrap])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="docs-error">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bootstrap || !specUrl) {
|
||||||
|
return <div className="docs-loading">Loading documentation…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideAuthorize = !bootstrap.show_authorize_button
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`docs-root variant-${bootstrap.variant}`}>
|
||||||
|
{bootstrap.welcome_name ? (
|
||||||
|
<div className="admin-banner">
|
||||||
|
Welcome, <span>{bootstrap.welcome_name}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hideAuthorize ? <style>{`.swagger-ui .btn.authorize { display: none !important; }`}</style> : null}
|
||||||
|
<SwaggerUI
|
||||||
|
url={specUrl}
|
||||||
|
docExpansion="none"
|
||||||
|
defaultModelsExpandDepth={-1}
|
||||||
|
deepLinking
|
||||||
|
tryItOutEnabled={bootstrap.try_it_out_enabled}
|
||||||
|
persistAuthorization={bootstrap.persist_authorization}
|
||||||
|
requestInterceptor={(req: {
|
||||||
|
headers: Record<string, string>
|
||||||
|
}) => {
|
||||||
|
if (authorization && !req.headers.Authorization) {
|
||||||
|
req.headers.Authorization = authorization
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
fetchParentAttendanceReportsList,
|
||||||
|
fetchParentAuthorizedUsers,
|
||||||
|
} from '../../api/session'
|
||||||
|
import { ParentParityShell } from './ParentParityShell'
|
||||||
|
|
||||||
|
/** Secondary / authorized users — `GET /api/v1/parents/authorized-users`. */
|
||||||
|
export function ParentAuthorizedUsersPage() {
|
||||||
|
const [payload, setPayload] = useState<string>('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetchParentAuthorizedUsers()
|
||||||
|
if (cancelled) return
|
||||||
|
setPayload(JSON.stringify(res, null, 2))
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Authorized users"
|
||||||
|
ciViewFile="add_second_parent.php"
|
||||||
|
legacyCiRoutes={['GET /api/v1/parents/authorized-users']}
|
||||||
|
>
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 320, overflow: 'auto' }}>
|
||||||
|
{payload}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submitted reports — `GET /api/v1/parents/attendance-reports`. */
|
||||||
|
export function ParentAttendanceReportsHistoryPage() {
|
||||||
|
const [payload, setPayload] = useState<string>('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetchParentAttendanceReportsList()
|
||||||
|
if (cancelled) return
|
||||||
|
setPayload(JSON.stringify(res, null, 2))
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="My attendance reports"
|
||||||
|
ciViewFile="report_attendance.php"
|
||||||
|
legacyCiRoutes={['GET /api/v1/parents/attendance-reports']}
|
||||||
|
>
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<pre className="small bg-light p-2 border rounded mb-0" style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||||
|
{payload}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<p className="small text-muted mt-2 mb-0">
|
||||||
|
New submission:{' '}
|
||||||
|
<Link to="/app/parent/report-attendance">Report attendance form</Link>
|
||||||
|
</p>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import { fetchParentAttendance } from '../../api/session'
|
||||||
|
import type { ParentAttendanceRow } from '../../api/types'
|
||||||
|
|
||||||
|
export function ParentAttendancePage() {
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
const schoolYearParam = params.get('school_year')
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<ParentAttendanceRow[]>([])
|
||||||
|
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
||||||
|
const [selectedYear, setSelectedYear] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchParentAttendance(schoolYearParam)
|
||||||
|
if (cancelled) return
|
||||||
|
setRows(data.attendance ?? [])
|
||||||
|
setSchoolYears(data.schoolYears ?? [])
|
||||||
|
setSelectedYear(data.selectedYear ?? null)
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRows([])
|
||||||
|
setSchoolYears([])
|
||||||
|
setSelectedYear(null)
|
||||||
|
setError(e instanceof Error ? e.message : 'Unable to load attendance.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [schoolYearParam])
|
||||||
|
|
||||||
|
function onYearChange(next: string) {
|
||||||
|
const nextParams = new URLSearchParams(params)
|
||||||
|
if (next) nextParams.set('school_year', next)
|
||||||
|
else nextParams.delete('school_year')
|
||||||
|
setParams(nextParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearOptions =
|
||||||
|
schoolYears.length > 0
|
||||||
|
? schoolYears
|
||||||
|
: selectedYear
|
||||||
|
? [{ school_year: selectedYear }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||||
|
Attendance Record
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="row g-2 align-items-end mb-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="col-auto">
|
||||||
|
<label className="form-label mb-0" htmlFor="school_year">
|
||||||
|
School year
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="school_year"
|
||||||
|
className="form-select"
|
||||||
|
value={schoolYearParam ?? selectedYear ?? ''}
|
||||||
|
onChange={(e) => onYearChange(e.target.value)}
|
||||||
|
disabled={loading || yearOptions.length === 0}
|
||||||
|
>
|
||||||
|
{yearOptions.length === 0 ? (
|
||||||
|
<option value="">—</option>
|
||||||
|
) : (
|
||||||
|
yearOptions.map((y) => (
|
||||||
|
<option key={y.school_year} value={y.school_year}>
|
||||||
|
{y.school_year}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button type="button" className="btn btn-success" disabled={loading}>
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-danger">{error}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-muted">No attendance rows for this school year.</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>First Name</th>
|
||||||
|
<th>Last Name</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={`${row.date}-${row.firstname}-${row.lastname}-${i}`}>
|
||||||
|
<td>{row.firstname}</td>
|
||||||
|
<td>{row.lastname}</td>
|
||||||
|
<td>{row.date}</td>
|
||||||
|
<td>{row.status}</td>
|
||||||
|
<td>{row.reason ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="small text-muted mt-4 mb-0">
|
||||||
|
<Link to="/app/parent/home">Parent dashboard</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/app/home">App home</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Navigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
/** CI uses `parent/edit_student/(:num)` — canonical SPA uses `parent/students/:id/edit`. */
|
||||||
|
export function RedirectParentLegacyStudentEdit() {
|
||||||
|
const { studentId } = useParams<{ studentId: string }>()
|
||||||
|
return <Navigate to={`/app/parent/students/${studentId ?? ''}/edit`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CI `parent/edit_emergency_contact/(:num)` and `parent/edit-emergency-contact/(:num)` →
|
||||||
|
* SPA `parent/emergency-contact/:contactId`.
|
||||||
|
*/
|
||||||
|
export function RedirectParentLegacyEmergencyContactEdit() {
|
||||||
|
const { contactId } = useParams<{ contactId: string }>()
|
||||||
|
return <Navigate to={`/app/parent/emergency-contact/${contactId ?? ''}`} replace />
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { fetchParentEmergencyContacts } from '../../api/session'
|
||||||
|
import type { ParentEmergencyContactRow } from '../../api/types'
|
||||||
|
import { ParentParityShell } from './ParentParityShell'
|
||||||
|
|
||||||
|
/** CI `parent/edit-emergency-contact` (no id) — GET /api/v1/parents/emergency-contacts */
|
||||||
|
export function ParentEmergencyContactsPage() {
|
||||||
|
const [rows, setRows] = useState<ParentEmergencyContactRow[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchParentEmergencyContacts()
|
||||||
|
if (cancelled) return
|
||||||
|
setRows(data.contacts ?? [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load contacts.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Emergency contacts"
|
||||||
|
ciViewFile="edit_emergency_contact.php"
|
||||||
|
legacyCiRoutes={['/parent/edit-emergency-contact']}
|
||||||
|
>
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-muted small mb-0">No emergency contacts.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-unstyled mb-0">
|
||||||
|
{rows.map((c) => (
|
||||||
|
<li key={c.id} className="border-bottom py-2">
|
||||||
|
<Link to={`/app/parent/emergency-contact/${c.id}`}>
|
||||||
|
<strong>{c.name || 'Contact'}</strong>
|
||||||
|
</Link>
|
||||||
|
<div className="small text-muted">
|
||||||
|
{[c.cellphone, c.email].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { fetchParentProfile } from '../../api/session'
|
||||||
|
import type { ParentProfileRecord } from '../../api/types'
|
||||||
|
import { useAuth } from '../../auth/AuthProvider'
|
||||||
|
import { ParentSectionNav } from './ParentSectionNav'
|
||||||
|
|
||||||
|
function displayName(p: ParentProfileRecord | null): string {
|
||||||
|
if (!p) return ''
|
||||||
|
const fn = (p.firstname ?? '').trim()
|
||||||
|
const ln = (p.lastname ?? '').trim()
|
||||||
|
const full = `${fn} ${ln}`.trim()
|
||||||
|
return full || (p.email ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParentHomePage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [profile, setProfile] = useState<ParentProfileRecord | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchParentProfile()
|
||||||
|
if (cancelled) return
|
||||||
|
setProfile(data.profile ?? null)
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setProfile(null)
|
||||||
|
setError(e instanceof Error ? e.message : 'Unable to load profile.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const greeting = useMemo(() => {
|
||||||
|
const fromApi = displayName(profile)
|
||||||
|
if (fromApi) return fromApi
|
||||||
|
return user?.name?.trim() || 'Parent'
|
||||||
|
}, [profile, user?.name])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||||
|
Parent dashboard
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted small mb-0">
|
||||||
|
Parity with CodeIgniter <code>Views/parent/parent_dashboard</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
{error}{' '}
|
||||||
|
<span className="text-muted">
|
||||||
|
Parent-only APIs require a parent account. Use the general{' '}
|
||||||
|
<Link to="/app/home">app home</Link> if you landed here by mistake.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3 mb-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card border-0 shadow-sm h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h6 text-muted">Account</h3>
|
||||||
|
<p className="mb-1">
|
||||||
|
<strong>{greeting}</strong>
|
||||||
|
</p>
|
||||||
|
{profile?.email ? (
|
||||||
|
<p className="small text-muted mb-0">{profile.email}</p>
|
||||||
|
) : null}
|
||||||
|
{profile?.cellphone ? (
|
||||||
|
<p className="small text-muted mb-0">{profile.cellphone}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card border-0 shadow-sm h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="h6 text-muted">Address</h3>
|
||||||
|
{profile?.address_street || profile?.city || profile?.state ? (
|
||||||
|
<p className="small mb-0">
|
||||||
|
{[profile.address_street, profile.city, profile.state, profile.zip]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted small mb-0">No address on file.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="border rounded p-3 bg-white h-100">
|
||||||
|
<h3 className="h6 mb-3">Shortcuts</h3>
|
||||||
|
<ul className="mb-0">
|
||||||
|
<li>
|
||||||
|
<Link to="/app/parent/attendance">Attendance record</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/app/home">App home</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="border rounded p-3 bg-white h-100">
|
||||||
|
<h3 className="h6 mb-2">All parent pages (CI parity)</h3>
|
||||||
|
<div className="small" style={{ maxHeight: 220, overflow: 'auto' }}>
|
||||||
|
<ParentSectionNav />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ParentSectionNav } from './ParentSectionNav'
|
||||||
|
|
||||||
|
export type ParentParitySpec = {
|
||||||
|
title: string
|
||||||
|
/** File name under `app/Views/parent/` (e.g. `calendar.php`). */
|
||||||
|
ciViewFile: string
|
||||||
|
/** Example CodeIgniter URL paths (with leading slash). */
|
||||||
|
legacyCiRoutes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShellProps = ParentParitySpec & {
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParentParityShell({
|
||||||
|
title,
|
||||||
|
ciViewFile,
|
||||||
|
legacyCiRoutes,
|
||||||
|
children,
|
||||||
|
}: ShellProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<h2 className="h4 text-success" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted small mb-0">
|
||||||
|
CodeIgniter view:{' '}
|
||||||
|
<code>
|
||||||
|
app/Views/parent/{ciViewFile}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{legacyCiRoutes?.length ? (
|
||||||
|
<p className="small text-muted mb-3">
|
||||||
|
Legacy routes:{' '}
|
||||||
|
{legacyCiRoutes.map((r, i) => (
|
||||||
|
<span key={r}>
|
||||||
|
{i > 0 ? ' · ' : null}
|
||||||
|
<code>{r}</code>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<aside className="col-lg-3">
|
||||||
|
<div className="border rounded bg-white shadow-sm p-2 small">
|
||||||
|
<div className="fw-semibold text-muted px-2 py-1 border-bottom mb-2">Parent pages</div>
|
||||||
|
<ParentSectionNav />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="col-lg-9">
|
||||||
|
<div className="alert alert-light border small mb-3">
|
||||||
|
This SPA route mirrors the CI parent view. Implement using the matching handlers in{' '}
|
||||||
|
<code>app_laravel/routes/api.php</code> (prefix <code>/api/v1/parents/...</code> where
|
||||||
|
available).
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 small mb-0">
|
||||||
|
<Link to="/app/parent/home">← Parent dashboard</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/app/home">App home</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
|
||||||
|
const LINKS: { to: string; label: string }[] = [
|
||||||
|
{ to: '/app/parent/home', label: 'Dashboard' },
|
||||||
|
{ to: '/app/parent/add-second-parent', label: 'Add second parent' },
|
||||||
|
{ to: '/app/parent/assignments', label: 'Assignments' },
|
||||||
|
{ to: '/app/parent/attendance', label: 'Attendance' },
|
||||||
|
{ to: '/app/parent/calendar', label: 'Calendar' },
|
||||||
|
{ to: '/app/parent/classes', label: 'Classes' },
|
||||||
|
{ to: '/app/parent/progress', label: 'Class progress' },
|
||||||
|
{ to: '/app/parent/edit-emergency-contact', label: 'Emergency contacts' },
|
||||||
|
{ to: '/app/parent/edit-all-students', label: 'Edit all students' },
|
||||||
|
{ to: '/app/parent/add-student-form', label: 'Add student form' },
|
||||||
|
{ to: '/app/parent/add-emergency-form', label: 'Add emergency form' },
|
||||||
|
{ to: '/app/parent/contact', label: 'Contact' },
|
||||||
|
{ to: '/app/parent/enroll-classes', label: 'Enroll in classes' },
|
||||||
|
{ to: '/app/parent/enroll/success', label: 'Enroll success' },
|
||||||
|
{ to: '/app/parent/enroll/failure', label: 'Enroll failure' },
|
||||||
|
{ to: '/app/parent/events', label: 'Events' },
|
||||||
|
{ to: '/app/parent/invoice-payment', label: 'Invoice payment' },
|
||||||
|
{ to: '/app/parent/no-kids-registered', label: 'No kids registered' },
|
||||||
|
{ to: '/app/parent/message', label: 'Message' },
|
||||||
|
{ to: '/app/parent/payment', label: 'Payments / invoices' },
|
||||||
|
{ to: '/app/parent/payment/success', label: 'Payment success' },
|
||||||
|
{ to: '/app/parent/register-student', label: 'Register student' },
|
||||||
|
{ to: '/app/parent/report-attendance', label: 'Report attendance' },
|
||||||
|
{ to: '/app/parent/attendance-reports', label: 'My attendance reports' },
|
||||||
|
{ to: '/app/parent/authorized-users', label: 'Authorized users' },
|
||||||
|
{ to: '/app/parent/update-profile', label: 'Update profile' },
|
||||||
|
{ to: '/app/parent/report-cards', label: 'Report cards' },
|
||||||
|
{ to: '/app/parent/scores', label: 'Scores' },
|
||||||
|
{ to: '/app/parent/success', label: 'Success message' },
|
||||||
|
{ to: '/app/parent/withdraw/success', label: 'Withdraw success' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ParentSectionNav() {
|
||||||
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
`d-block px-2 py-1 rounded text-decoration-none small ${
|
||||||
|
isActive ? 'bg-success text-white' : 'text-body'
|
||||||
|
}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="nav flex-column gap-1" aria-label="Parent section">
|
||||||
|
{LINKS.map(({ to, label }) => (
|
||||||
|
<NavLink key={to} to={to} className={linkClass} end={to === '/app/parent/home'}>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { ParentParityShell } from './ParentParityShell'
|
||||||
|
import {
|
||||||
|
fetchAttendanceReportForm,
|
||||||
|
fetchParentEnrollments,
|
||||||
|
fetchParentEventsOverview,
|
||||||
|
fetchParentInvoices,
|
||||||
|
fetchParentProfile,
|
||||||
|
fetchParentRegistrationOverview,
|
||||||
|
submitContactMessage,
|
||||||
|
} from '../../api/session'
|
||||||
|
import type { ParentEnrollmentStudent, ParentInvoiceRow } from '../../api/types'
|
||||||
|
import { useAuth } from '../../auth/AuthProvider'
|
||||||
|
|
||||||
|
/** `Views/parent/contact.php` — POST /api/v1/contact */
|
||||||
|
export function ParentContactPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [subject, setSubject] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchParentProfile()
|
||||||
|
const p = res.profile
|
||||||
|
if (cancelled || !p) return
|
||||||
|
const fn = (p.firstname ?? '').trim()
|
||||||
|
const ln = (p.lastname ?? '').trim()
|
||||||
|
const full = `${fn} ${ln}`.trim()
|
||||||
|
if (full) setName(full)
|
||||||
|
if (p.email) setEmail(String(p.email))
|
||||||
|
} catch {
|
||||||
|
/* optional */
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.name) return
|
||||||
|
setName((n) => n || user.name)
|
||||||
|
}, [user?.name])
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
const res = await submitContactMessage({ name, email, subject, message })
|
||||||
|
if (res.status) {
|
||||||
|
setStatus(res.message ?? 'Message sent.')
|
||||||
|
setSubject('')
|
||||||
|
setMessage('')
|
||||||
|
} else {
|
||||||
|
setError('Could not send message.')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Request failed.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Contact"
|
||||||
|
ciViewFile="contact.php"
|
||||||
|
legacyCiRoutes={['/parent/contact']}
|
||||||
|
>
|
||||||
|
{status ? <div className="alert alert-success small">{status}</div> : null}
|
||||||
|
{error ? <div className="alert alert-danger small">{error}</div> : null}
|
||||||
|
<form className="row g-3" onSubmit={onSubmit}>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label" htmlFor="pc_name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pc_name"
|
||||||
|
className="form-control"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label" htmlFor="pc_email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pc_email"
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="pc_subject">
|
||||||
|
Subject
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pc_subject"
|
||||||
|
className="form-control"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="pc_message">
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="pc_message"
|
||||||
|
className="form-control"
|
||||||
|
rows={5}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<button type="submit" className="btn btn-success" disabled={loading}>
|
||||||
|
{loading ? 'Sending…' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
|
||||||
|
export function ParentEnrollClassesPage() {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
||||||
|
const [year, setYear] = useState<string | null>(null)
|
||||||
|
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchParentEnrollments(null)
|
||||||
|
if (cancelled) return
|
||||||
|
setSchoolYears(data.schoolYears ?? [])
|
||||||
|
setYear(data.selectedYear ?? null)
|
||||||
|
setStudents(data.students ?? [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Enroll in classes"
|
||||||
|
ciViewFile="enroll_classes.php"
|
||||||
|
legacyCiRoutes={['/parent/enroll_classes']}
|
||||||
|
>
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="small text-muted mb-2">
|
||||||
|
School year: <code>{year ?? '—'}</code>. Updates use{' '}
|
||||||
|
<code>POST /api/v1/parents/enrollments</code> with enroll / withdraw payloads.
|
||||||
|
</p>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Class</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{students.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2} className="text-muted">
|
||||||
|
No students for this overview.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
students.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>
|
||||||
|
{s.firstname} {s.lastname}
|
||||||
|
</td>
|
||||||
|
<td>{s.class_section ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{schoolYears.length > 1 ? (
|
||||||
|
<p className="small mb-0">Additional years in response: {schoolYears.length}</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
|
||||||
|
export function ParentPaymentInvoicesPage() {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [rows, setRows] = useState<ParentInvoiceRow[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchParentInvoices(null)
|
||||||
|
if (cancelled) return
|
||||||
|
setRows(data.invoices ?? [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load invoices.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Payments & invoices"
|
||||||
|
ciViewFile="payment_view.php"
|
||||||
|
legacyCiRoutes={['/parent/payment']}
|
||||||
|
>
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Invoice</th>
|
||||||
|
<th>Balance</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-muted">
|
||||||
|
No invoices.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((inv) => (
|
||||||
|
<tr key={inv.id}>
|
||||||
|
<td>{inv.id}</td>
|
||||||
|
<td>{inv.invoice_number ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
{typeof inv.balance === 'number' ? inv.balance.toFixed(2) : '—'}
|
||||||
|
</td>
|
||||||
|
<td>{inv.status ?? '—'}</td>
|
||||||
|
<td>{inv.due_date ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Views/parent/event_participation.php` — GET/POST /api/v1/parents/events … */
|
||||||
|
export function ParentEventsPage() {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [summary, setSummary] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchParentEventsOverview()
|
||||||
|
if (cancelled) return
|
||||||
|
setSummary(JSON.stringify(data, null, 2))
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load events.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Event participation"
|
||||||
|
ciViewFile="event_participation.php"
|
||||||
|
legacyCiRoutes={['/parent/events']}
|
||||||
|
>
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 320, overflow: 'auto' }}>
|
||||||
|
{summary}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Views/parent/register_student.php` — GET /api/v1/parents/registration */
|
||||||
|
export function ParentRegisterStudentPage() {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [summary, setSummary] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchParentRegistrationOverview()
|
||||||
|
if (cancelled) return
|
||||||
|
setSummary(JSON.stringify(data, null, 2))
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load registration.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Register student"
|
||||||
|
ciViewFile="register_student.php"
|
||||||
|
legacyCiRoutes={['/parent/register_student', '/parent/child_register']}
|
||||||
|
>
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||||
|
{summary}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Views/parent/report_attendance.php` — form: GET /api/v1/parents/attendance-reports/form */
|
||||||
|
export function ParentReportAttendancePage() {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [summary, setSummary] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceReportForm()
|
||||||
|
if (cancelled) return
|
||||||
|
setSummary(JSON.stringify(data, null, 2))
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled)
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load attendance report form.')
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Report attendance"
|
||||||
|
ciViewFile="report_attendance.php"
|
||||||
|
legacyCiRoutes={['/parent/report-attendance']}
|
||||||
|
>
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<pre className="small bg-light p-2 border rounded" style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||||
|
{summary}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<p className="small text-muted mt-2 mb-0">
|
||||||
|
Submit payloads: <code>POST /api/v1/parents/attendance-reports</code>,{' '}
|
||||||
|
<code>PATCH /api/v1/parents/attendance-reports/{'{id}'}</code>.
|
||||||
|
</p>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { FC } from 'react'
|
||||||
|
import { ParentParityShell, type ParentParitySpec } from './ParentParityShell'
|
||||||
|
|
||||||
|
export function createParentParityPage(spec: ParentParitySpec): FC {
|
||||||
|
const Page: FC = () => <ParentParityShell {...spec} />
|
||||||
|
Page.displayName = `ParentParity(${spec.ciViewFile.replace(/\W/g, '_')})`
|
||||||
|
return Page
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import { ParentParityShell } from './ParentParityShell'
|
||||||
|
|
||||||
|
export function ParentProgressViewPage() {
|
||||||
|
const { progressId } = useParams<{ progressId: string }>()
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Class progress detail"
|
||||||
|
ciViewFile="class_progress_view.php"
|
||||||
|
legacyCiRoutes={[`/parent/progress/view/${progressId ?? ''}`]}
|
||||||
|
>
|
||||||
|
<p className="small mb-0">
|
||||||
|
Progress id: <code>{progressId}</code>. Attachment downloads map to{' '}
|
||||||
|
<code>/api/v1/class-progress/…</code> in Laravel when wired.
|
||||||
|
</p>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParentEmergencyContactEditPage() {
|
||||||
|
const { contactId } = useParams<{ contactId: string }>()
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Emergency contact"
|
||||||
|
ciViewFile="edit_emergency_contact.php"
|
||||||
|
legacyCiRoutes={[
|
||||||
|
'/parent/edit-emergency-contact',
|
||||||
|
`/parent/edit_emergency_contact/${contactId ?? ''}`,
|
||||||
|
`/parent/edit-emergency-contact/${contactId ?? ''}`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p className="small mb-2">
|
||||||
|
Contact id: <code>{contactId}</code>. Use{' '}
|
||||||
|
<code>GET/PATCH /api/v1/parents/emergency-contacts</code> when implementing the form.
|
||||||
|
</p>
|
||||||
|
<Link to="/app/parent/register-student" className="small">
|
||||||
|
Registration & contacts overview →
|
||||||
|
</Link>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parity for CI student edit modal / edit_student flows (`edit_student_modal.php`). */
|
||||||
|
export function ParentStudentEditPage() {
|
||||||
|
const { studentId } = useParams<{ studentId: string }>()
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Edit student"
|
||||||
|
ciViewFile="edit_student_modal.php"
|
||||||
|
legacyCiRoutes={[
|
||||||
|
`/parent/edit_student/${studentId ?? ''}`,
|
||||||
|
`/parent/edit-student/${studentId ?? ''}`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p className="small mb-0">
|
||||||
|
Student id: <code>{studentId}</code>. Wire to{' '}
|
||||||
|
<code>PATCH /api/v1/parents/students/{'{studentId}'}</code>.
|
||||||
|
</p>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CI `parent/report-cards/view/(:num)` — report card PDF / viewer. */
|
||||||
|
export function ParentReportCardsViewPage() {
|
||||||
|
const { reportId } = useParams<{ reportId: string }>()
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Report card"
|
||||||
|
ciViewFile="report_cards.php"
|
||||||
|
legacyCiRoutes={[`/parent/report-cards/view/${reportId ?? ''}`]}
|
||||||
|
>
|
||||||
|
<p className="small mb-0">
|
||||||
|
Report / student id: <code>{reportId}</code>. Laravel:{' '}
|
||||||
|
<code>/api/v1/reports/report-cards/students/{'{studentId}'}</code> (with parent auth).
|
||||||
|
</p>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CI `parent/progress/attachment/(:num)` — file download route. */
|
||||||
|
export function ParentProgressAttachmentPage() {
|
||||||
|
const { attachmentId } = useParams<{ attachmentId: string }>()
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Class progress attachment"
|
||||||
|
ciViewFile="class_progress_view.php"
|
||||||
|
legacyCiRoutes={[`/parent/progress/attachment/${attachmentId ?? ''}`]}
|
||||||
|
>
|
||||||
|
<p className="small mb-0">
|
||||||
|
Attachment id: <code>{attachmentId}</code>. Wire download to{' '}
|
||||||
|
<code>/api/v1/class-progress/attachments/{'{attachmentId}'}</code>.
|
||||||
|
</p>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CI `parent/progress/attachment-file/(:num)` — legacy file stream. */
|
||||||
|
export function ParentProgressAttachmentFilePage() {
|
||||||
|
const { fileId } = useParams<{ fileId: string }>()
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Class progress attachment (legacy)"
|
||||||
|
ciViewFile="class_progress_view.php"
|
||||||
|
legacyCiRoutes={[`/parent/progress/attachment-file/${fileId ?? ''}`]}
|
||||||
|
>
|
||||||
|
<p className="small mb-0">
|
||||||
|
File id: <code>{fileId}</code>. Legacy path uses{' '}
|
||||||
|
<code>/api/v1/class-progress/{'{id}'}/attachment</code>.
|
||||||
|
</p>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CI `POST parent/report-cards/sign/(:num)` — acknowledgement / signature flow. */
|
||||||
|
export function ParentReportCardsSignPage() {
|
||||||
|
const { studentId } = useParams<{ studentId: string }>()
|
||||||
|
return (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Sign report card"
|
||||||
|
ciViewFile="report_cards.php"
|
||||||
|
legacyCiRoutes={[`POST /parent/report-cards/sign/${studentId ?? ''}`]}
|
||||||
|
>
|
||||||
|
<p className="small mb-0">
|
||||||
|
Student id: <code>{studentId}</code>. Wire <code>POST</code> to Laravel report-cards
|
||||||
|
acknowledgement endpoints under <code>/api/v1/reports/report-cards/…</code>.
|
||||||
|
</p>
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createParentParityPage } from './createParentParityPage'
|
||||||
|
|
||||||
|
/** CI `parent/edit-all-students` — bulk edit modal (composed from registration views). */
|
||||||
|
export const ParentEditAllStudentsPage = createParentParityPage({
|
||||||
|
title: 'Edit all students',
|
||||||
|
ciViewFile: 'register_student.php',
|
||||||
|
legacyCiRoutes: ['/parent/edit-all-students'],
|
||||||
|
})
|
||||||
|
|
||||||
|
/** CI `parent/add-emergency-form` — partial / modal flow. */
|
||||||
|
export const ParentAddEmergencyFormPage = createParentParityPage({
|
||||||
|
title: 'Add emergency contact',
|
||||||
|
ciViewFile: 'edit_emergency_contact.php',
|
||||||
|
legacyCiRoutes: ['/parent/add-emergency-form'],
|
||||||
|
})
|
||||||
|
|
||||||
|
/** CI `parent/add-student-form` — add student flow. */
|
||||||
|
export const ParentAddStudentFormPage = createParentParityPage({
|
||||||
|
title: 'Add student',
|
||||||
|
ciViewFile: 'register_student.php',
|
||||||
|
legacyCiRoutes: ['/parent/add-student-form'],
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST `parent/updateProfile/(:num)` — profile updates (PATCH in Laravel API). */
|
||||||
|
export const ParentUpdateProfileParityPage = createParentParityPage({
|
||||||
|
title: 'Update profile',
|
||||||
|
ciViewFile: 'register_student.php',
|
||||||
|
legacyCiRoutes: ['POST /parent/updateProfile/:id'],
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createParentParityPage } from './createParentParityPage'
|
||||||
|
|
||||||
|
export const ParentAddSecondParentPage = createParentParityPage({
|
||||||
|
title: 'Add second parent',
|
||||||
|
ciViewFile: 'add_second_parent.php',
|
||||||
|
legacyCiRoutes: ['/parent/add_second_parent'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentAssignmentsPage = createParentParityPage({
|
||||||
|
title: 'Assignments',
|
||||||
|
ciViewFile: 'assignments.php',
|
||||||
|
legacyCiRoutes: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentCalendarPage = createParentParityPage({
|
||||||
|
title: 'School calendar',
|
||||||
|
ciViewFile: 'calendar.php',
|
||||||
|
legacyCiRoutes: ['/parent/calendar'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentClassesPage = createParentParityPage({
|
||||||
|
title: 'Classes',
|
||||||
|
ciViewFile: 'classes.php',
|
||||||
|
legacyCiRoutes: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentProgressListPage = createParentParityPage({
|
||||||
|
title: 'Class progress',
|
||||||
|
ciViewFile: 'class_progress_list.php',
|
||||||
|
legacyCiRoutes: ['/parent/progress'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentInvoicePaymentPage = createParentParityPage({
|
||||||
|
title: 'Invoice payment',
|
||||||
|
ciViewFile: 'invoice_payment.php',
|
||||||
|
legacyCiRoutes: ['/parent/invoice_payment'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentNoKidsRegisteredPage = createParentParityPage({
|
||||||
|
title: 'No kids registered',
|
||||||
|
ciViewFile: 'no_kids_registred.php',
|
||||||
|
legacyCiRoutes: ['/parent/child_register', '/parent/register_student'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentMessagePage = createParentParityPage({
|
||||||
|
title: 'Message',
|
||||||
|
ciViewFile: 'parent_message.php',
|
||||||
|
legacyCiRoutes: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentEnrollFailurePage = createParentParityPage({
|
||||||
|
title: 'Enrollment unsuccessful',
|
||||||
|
ciViewFile: 'enroll_failure.php',
|
||||||
|
legacyCiRoutes: ['/parent/enroll_failure'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentEnrollSuccessPage = createParentParityPage({
|
||||||
|
title: 'Enrollment successful',
|
||||||
|
ciViewFile: 'enroll_success.php',
|
||||||
|
legacyCiRoutes: ['/parent/enroll_success'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentPaymentSuccessPage = createParentParityPage({
|
||||||
|
title: 'Payment success',
|
||||||
|
ciViewFile: 'payment_success.php',
|
||||||
|
legacyCiRoutes: ['/parent/payment_success'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentReportCardsPage = createParentParityPage({
|
||||||
|
title: 'Report cards',
|
||||||
|
ciViewFile: 'report_cards.php',
|
||||||
|
legacyCiRoutes: ['/parent/report-cards'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentScoresPage = createParentParityPage({
|
||||||
|
title: 'Scores',
|
||||||
|
ciViewFile: 'scores.php',
|
||||||
|
legacyCiRoutes: ['/parent/scores'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentSuccessMessagePage = createParentParityPage({
|
||||||
|
title: 'Success',
|
||||||
|
ciViewFile: 'success_message.php',
|
||||||
|
legacyCiRoutes: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ParentWithdrawSuccessPage = createParentParityPage({
|
||||||
|
title: 'Withdrawal successful',
|
||||||
|
ciViewFile: 'withdraw_success.php',
|
||||||
|
legacyCiRoutes: [],
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/* Mirrors app_codeigniter/public/assets/css/register-form.css for auth/register pages */
|
||||||
|
|
||||||
|
.ci-auth-bg {
|
||||||
|
background-color: #deffe1;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-form.ci-parity form {
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 60px;
|
||||||
|
border-radius: 30px;
|
||||||
|
margin: auto;
|
||||||
|
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-form.ci-parity .form-control.item,
|
||||||
|
.registration-form.ci-parity .form-select.item {
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-form.ci-parity .btn.item {
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-form.ci-parity .btn-success.item {
|
||||||
|
background-color: #099909;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-form.ci-parity .btn-secondary.item {
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #6d7179;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.registration-form.ci-parity form {
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
declare module 'swagger-ui-react' {
|
||||||
|
import type { ComponentType } from 'react'
|
||||||
|
|
||||||
|
type SwaggerRequest = {
|
||||||
|
headers: Record<string, string>
|
||||||
|
url?: string
|
||||||
|
method?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwaggerUIProps {
|
||||||
|
url?: string
|
||||||
|
docExpansion?: string
|
||||||
|
defaultModelsExpandDepth?: number
|
||||||
|
deepLinking?: boolean
|
||||||
|
tryItOutEnabled?: boolean
|
||||||
|
persistAuthorization?: boolean
|
||||||
|
requestInterceptor?: (req: SwaggerRequest) => SwaggerRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
const SwaggerUI: ComponentType<SwaggerUIProps>
|
||||||
|
export default SwaggerUI
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type DocsBootstrap = {
|
||||||
|
variant: 'public' | 'full'
|
||||||
|
title: string
|
||||||
|
openapi_url: string
|
||||||
|
try_it_out_enabled: boolean
|
||||||
|
persist_authorization: boolean
|
||||||
|
show_authorize_button: boolean
|
||||||
|
welcome_name: string | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
const apiTarget = process.env.VITE_PROXY_API ?? 'http://localhost:8000'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user