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