init project

This commit is contained in:
root
2026-04-23 00:21:02 -04:00
parent 23826c5528
commit 9191fd32f0
64 changed files with 10229 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
node_modules/
dist/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment files
.env
.env.*
!.env.example
# Editor/OS files
.DS_Store
Thumbs.db
.vscode/
.idea/
+32
View File
@@ -0,0 +1,32 @@
FROM node:20-bookworm-slim AS builder
WORKDIR /app
# If npm/yarn fail with "unable to get local issuer certificate" during install, build with:
# docker compose build --build-arg NPM_STRICT_SSL=false client-prod
# (Only use on trusted networks; prefer mounting your org CA via NODE_EXTRA_CA_CERTS instead.)
ARG NPM_STRICT_SSL=true
RUN if [ "$NPM_STRICT_SSL" = "false" ]; then npm config set strict-ssl false; fi
COPY package.json package-lock.json ./
ENV CI=true \
npm_config_maxsockets=1 \
NPM_CONFIG_LEGACY_PEER_DEPS=true
# Mitigate npm "Exit handler never called!" in some Docker/npm builds (see npm/cli issues).
RUN npm install --no-audit --no-fund
COPY . .
ARG VITE_API_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
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-bookworm-slim
WORKDIR /app
ARG NPM_STRICT_SSL=true
RUN if [ "$NPM_STRICT_SSL" = "false" ]; then npm config set strict-ssl false; fi
COPY package.json package-lock.json ./
ENV CI=true \
npm_config_maxsockets=1 \
NPM_CONFIG_LEGACY_PEER_DEPS=true
RUN npm install --no-audit --no-fund
COPY docker-entrypoint.dev.sh /docker-entrypoint.dev.sh
RUN chmod +x /docker-entrypoint.dev.sh
EXPOSE 5173
ENTRYPOINT ["/docker-entrypoint.dev.sh"]
+32
View File
@@ -0,0 +1,32 @@
services:
client:
build:
context: .
dockerfile: Dockerfile.dev
args:
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
ports:
- '5173:5173'
volumes:
- .:/app
- client_node_modules:/app/node_modules
environment:
# Laravel API — Windows/macOS Docker Desktop resolves this; Linux uses extra_hosts below.
VITE_PROXY_API: ${VITE_PROXY_API:-http://host.docker.internal:8000}
extra_hosts:
- 'host.docker.internal:host-gateway'
client-prod:
build:
context: .
dockerfile: Dockerfile
args:
VITE_API_ORIGIN: ${VITE_API_ORIGIN:-}
NPM_STRICT_SSL: ${NPM_STRICT_SSL:-true}
ports:
- '8080:80'
profiles:
- prod
volumes:
client_node_modules:
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
cd /app
if [ ! -f node_modules/.installed ]; then
export CI=true
export npm_config_maxsockets=1
export NPM_CONFIG_LEGACY_PEER_DEPS=true
npm install --no-audit --no-fund
touch node_modules/.installed
fi
exec npm run dev -- --host 0.0.0.0 --port 5173
+23
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+5408
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Run the Vite dev container from WSL (Docker Desktop with WSL2 backend, or Docker Engine in WSL).
#
# Usage (from repo root):
# chmod +x scripts/docker-up-wsl.sh
# ./scripts/docker-up-wsl.sh
#
# Or from anywhere:
# bash /mnt/d/Test/alrahma/client_laravel/scripts/docker-up-wsl.sh
#
# Windows D: drive in WSL is usually: /mnt/d/Test/alrahma/client_laravel
#
# Laravel API proxy: defaults to host port 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
+92
View File
@@ -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()
+85
View File
@@ -0,0 +1,85 @@
/**
* Smoke-test the same API routes the Vite client uses.
* Usage: node scripts/test-api.mjs
* $env:API_BASE="http://127.0.0.1:5173"; node scripts/test-api.mjs # via Vite proxy
*/
const base = (process.env.API_BASE ?? 'http://127.0.0.1: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
View File
@@ -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
View File
@@ -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>
)
}
+52
View File
@@ -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
}
+127
View File
@@ -0,0 +1,127 @@
import { apiUrl } from '../lib/apiOrigin'
export type FullRegisterPayload = {
firstname: string
lastname: string
gender: string
cellphone: string
email: string
confirm_email: string
address_street: string
apt?: string
city: string
state: string
zip: string
is_parent: boolean
no_second_parent_info: boolean
second_firstname?: string
second_lastname?: string
second_gender?: string
second_email?: string
second_cellphone?: string
accept_school_policy: boolean
captcha: string
}
export async function fetchRegisterCaptcha(): Promise<{ challenge: string }> {
const res = await fetch(apiUrl('/api/v1/auth/register/captcha'), {
method: 'GET',
headers: { Accept: 'application/json' },
})
const body: unknown = await res.json().catch(() => null)
if (!res.ok || !body || typeof body !== 'object') {
throw new Error('Could not load CAPTCHA.')
}
const o = body as { ok?: boolean; captcha?: string }
if (!o.ok || typeof o.captcha !== 'string') {
throw new Error('Invalid CAPTCHA response.')
}
return { challenge: o.captcha }
}
export type FullRegisterSuccess = {
ok: true
message?: string
registration?: unknown
}
export type FullRegisterFailure = {
ok: false
message: string
errors?: Record<string, string[]>
}
export async function submitFullRegistration(
payload: FullRegisterPayload,
): Promise<FullRegisterSuccess | FullRegisterFailure> {
const body: Record<string, unknown> = {
firstname: payload.firstname.trim(),
lastname: payload.lastname.trim(),
gender: payload.gender,
cellphone: payload.cellphone.trim(),
email: payload.email.trim(),
confirm_email: payload.confirm_email.trim(),
address_street: payload.address_street.trim(),
apt: payload.apt?.trim() || '',
city: payload.city.trim(),
state: payload.state,
zip: payload.zip.trim(),
is_parent: payload.is_parent,
no_second_parent_info: payload.no_second_parent_info,
accept_school_policy: payload.accept_school_policy,
captcha: payload.captcha.trim(),
}
if (payload.is_parent && !payload.no_second_parent_info) {
body.second_firstname = payload.second_firstname?.trim() ?? ''
body.second_lastname = payload.second_lastname?.trim() ?? ''
body.second_gender = payload.second_gender ?? ''
body.second_email = payload.second_email?.trim() ?? ''
body.second_cellphone = payload.second_cellphone?.trim() ?? ''
}
const res = await fetch(apiUrl('/api/v1/auth/register'), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
const data: unknown = await res.json().catch(() => null)
if (res.ok && data && typeof data === 'object' && 'ok' in data && (data as { ok?: boolean }).ok === true) {
return { ok: true, message: (data as { message?: string }).message, registration: (data as { registration?: unknown }).registration }
}
let message = 'Registration failed.'
let errors: Record<string, string[]> | undefined
if (data && typeof data === 'object') {
const o = data as { message?: string; errors?: Record<string, string[] | string> }
if (typeof o.message === 'string') message = o.message
if (o.errors && typeof o.errors === 'object') {
errors = {}
for (const [k, v] of Object.entries(o.errors)) {
if (Array.isArray(v)) errors[k] = v.map(String)
else errors[k] = [String(v)]
}
}
}
return { ok: false, message, errors }
}
export async function fetchSchoolPolicyHtml(): Promise<string> {
const res = await fetch(apiUrl('/api/v1/policies/school'), {
headers: { Accept: 'application/json' },
})
const json = (await res.json().catch(() => null)) as {
data?: { policy?: { content?: string } }
}
const html = json?.data?.policy?.content
if (!res.ok || !html) {
return '<p>Unable to load policy. Please contact the school.</p>'
}
return html
}
+122
View File
@@ -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')
}
+138
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+131
View File
@@ -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
}
+13
View File
@@ -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 />
}
+10
View File
@@ -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' },
]
+78
View File
@@ -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;
}
+194
View File
@@ -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>
)
}
+92
View File
@@ -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>
)
}
+11
View File
@@ -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}`
}
+21
View File
@@ -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('/')}`
}
+24
View File
@@ -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',
},
}
+324
View File
@@ -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' },
}
+65
View File
@@ -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())
}
+11
View File
@@ -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>,
)
+68
View File
@@ -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>
)
}
+105
View File
@@ -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>
)
}
+54
View File
@@ -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>
)
}
+61
View File
@@ -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>
)
}
+44
View File
@@ -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&apos;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>
)
}
+78
View File
@@ -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>
)
}
+146
View File
@@ -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>
)
}
+610
View File
@@ -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>
)
}
+102
View File
@@ -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>
)
}
+142
View File
@@ -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>
)
}
+136
View File
@@ -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>
)
}
+73
View File
@@ -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>
)
}
+51
View File
@@ -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>
)
}
+437
View File
@@ -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
}
+129
View File
@@ -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 &amp; 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'],
})
+91
View File
@@ -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: [],
})
+54
View File
@@ -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;
}
}
+22
View File
@@ -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
}
+9
View File
@@ -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
}
+25
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -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"]
}
+23
View File
@@ -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,
},
},
},
})