Compare commits
134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b459a6ca | ||
|
|
adc7a2a05b | ||
|
|
29554ebdbd | ||
|
|
434643596e | ||
|
|
313ab724b8 | ||
|
|
ee7d7f7cac | ||
|
|
0dd18c4fd4 | ||
|
|
5869c741d7 | ||
|
|
5b6e461706 | ||
|
|
53adc74a1c | ||
|
|
9a7cc86a75 | ||
|
|
6cba932146 | ||
|
|
ba829be8e9 | ||
|
|
9f58917c39 | ||
|
|
a375fbe37f | ||
|
|
38ca23dc3e | ||
|
|
8fe8edb341 | ||
|
|
143fff6414 | ||
|
|
c216df8021 | ||
|
|
f7bb84f9b8 | ||
|
|
cbff4deef2 | ||
|
|
85ecae7147 | ||
|
|
8a79675497 | ||
|
|
6951bc1700 | ||
|
|
e6f6bd5c3a | ||
|
|
b3ec09fbef | ||
|
|
765573163d | ||
|
|
1505adb154 | ||
|
|
ade0be97f6 | ||
|
|
96db086ecb | ||
|
|
6610abbd72 | ||
|
|
489c2208c5 | ||
|
|
3db843a6f9 | ||
|
|
79e7d4df9c | ||
|
|
2e3cb134d1 | ||
|
|
aca4d045b2 | ||
|
|
a0afbc90f5 | ||
|
|
57429f3a75 | ||
|
|
01a8a59349 | ||
|
|
0150ca3c32 | ||
|
|
33b9278085 | ||
|
|
9196af12f8 | ||
|
|
f6e2ab63dd | ||
|
|
f1c1a72f44 | ||
|
|
1e7b387b43 | ||
|
|
135ba4d26d | ||
|
|
8c1623256b | ||
|
|
b246106876 | ||
|
|
d5a7464e15 | ||
|
|
061cd22748 | ||
|
|
55da04a3f7 | ||
|
|
2a6359e247 | ||
|
|
3afba919c4 | ||
|
|
07ceef1294 | ||
|
|
50c7bb68bb | ||
|
|
3ffbb11552 | ||
|
|
669a6616d8 | ||
|
|
65bce2c52d | ||
|
|
cd37771c52 | ||
|
|
df59207add | ||
|
|
2d15b7cc03 | ||
|
|
d3bd25d544 | ||
|
|
bf592886c6 | ||
|
|
fedbd903ff | ||
|
|
65ce1bede8 | ||
|
|
67e47fc47f | ||
|
|
0db53bfd9c | ||
|
|
e68eef8865 | ||
|
|
77282893f3 | ||
|
|
6642d5fa22 | ||
|
|
b2973dc6ad | ||
|
|
b69c4d3e4f | ||
|
|
1c9ed4bd65 | ||
|
|
de21c56543 | ||
|
|
7a1032a3bd | ||
|
|
63d6d1bbca | ||
|
|
98a4212e46 | ||
|
|
510bef02b6 | ||
|
|
9438e17f88 | ||
|
|
61ef95a35e | ||
|
|
0f5dbd12bf | ||
|
|
6ac5ab7b5c | ||
|
|
138dfb1531 | ||
|
|
9d78f06c97 | ||
|
|
b0b2a7f2c3 | ||
|
|
83adcce951 | ||
|
|
b0c7758135 | ||
|
|
3a65cf436b | ||
|
|
6bd85671d2 | ||
|
|
5284d30f84 | ||
|
|
fb1795fd2b | ||
|
|
7b546c933e | ||
|
|
4139eb8fd3 | ||
|
|
308889937c | ||
|
|
0345258247 | ||
|
|
83f17c9ab6 | ||
|
|
5e21a2c7fc | ||
|
|
e27d1f5c8d | ||
|
|
b1681d86fb | ||
|
|
d789586fca | ||
|
|
3c0fd4f647 | ||
|
|
679653c118 | ||
|
|
ab1f8111f6 | ||
|
|
d3c2a9bdff | ||
|
|
06f761e750 | ||
|
|
4d10d75003 | ||
|
|
cb8c2dab74 | ||
|
|
6bbed42f55 | ||
|
|
2ee4e4afe7 | ||
|
|
0f4956d4a4 | ||
|
|
d17acffd41 | ||
|
|
fc6b6c3d10 | ||
|
|
51c0bfaeaf | ||
|
|
856816c36a | ||
|
|
067df34c50 | ||
|
|
00a6f941c7 | ||
|
|
c7207eb805 | ||
|
|
a64836c94a | ||
|
|
11c139b410 | ||
|
|
d5c53f2c17 | ||
|
|
7c94594798 | ||
|
|
43040d8ae0 | ||
|
|
f8160834fb | ||
|
|
341c551ea5 | ||
|
|
de78090963 | ||
|
|
80434b7668 | ||
|
|
1efdadb652 | ||
|
|
ca9e197cdc | ||
|
|
dc0b1689c1 | ||
|
|
df0abe676a | ||
|
|
c992e86189 | ||
|
|
53e298bbe2 | ||
|
|
5c100a015d | ||
|
|
5e5aad54cb |
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cursor*
|
||||||
|
*.log
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
PORT_FRONTEND=5173
|
|
||||||
PORT_FRONTEND_HMR=3002
|
|
||||||
PORT_API=800
|
|
||||||
PORT_SUPABASE=8000
|
|
||||||
|
|
||||||
VITE_PORT_FRONTEND=5173
|
|
||||||
VITE_PORT_FRONTEND_HMR=5173
|
|
||||||
|
|
||||||
VITE_APP_NAME=Classroom Copilot
|
|
||||||
VITE_SUPER_ADMIN_EMAIL=[email protected]
|
|
||||||
VITE_DEV=true
|
|
||||||
VITE_FRONTEND_SITE_URL=https://app.classroomcopilot.ai
|
|
||||||
VITE_APP_HMR_URL=https://app.classroomcopilot.ai
|
|
||||||
VITE_SUPABASE_URL=https://supa.classroomcopilot.ai
|
|
||||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
|
||||||
VITE_API_URL=https://api.classroomcopilot.ai
|
|
||||||
VITE_API_BASE=https://api.classroomcopilot.ai
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
PORT_FRONTEND=5173
|
|
||||||
PORT_FRONTEND_HMR=3002
|
|
||||||
PORT_API=800
|
|
||||||
PORT_SUPABASE=8000
|
|
||||||
|
|
||||||
HOST_FRONTEND=localhost:5173
|
|
||||||
VITE_PORT_FRONTEND=5173
|
|
||||||
VITE_PORT_FRONTEND_HMR=5173
|
|
||||||
|
|
||||||
VITE_APP_NAME=Classroom Copilot
|
|
||||||
VITE_SUPER_ADMIN_EMAIL=[email protected]
|
|
||||||
VITE_DEV=true
|
|
||||||
VITE_FRONTEND_SITE_URL=http://localhost:5173
|
|
||||||
VITE_APP_HMR_URL=http://localhost:5173
|
|
||||||
VITE_SUPABASE_URL=http://localhost:8000
|
|
||||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
|
||||||
VITE_API_URL=http://localhost:8080
|
|
||||||
VITE_API_BASE=http://localhost:8080
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzcxODE3MjE5LCJpc3MiOiJzdXBhYmFzZSIsInN1YiI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMCIsImV4cCI6MzM0ODYxNzIxOSwicm9sZSI6ImFub24ifQ.JbmQOTOBAzpBJ9JttOrGlo_JTXDXhCjYMjKiFvRkaNQ
|
||||||
|
PORT_FRONTEND=5173
|
||||||
|
PORT_FRONTEND_HMR=3002
|
||||||
|
PORT_API=8000
|
||||||
|
PORT_SUPABASE=8000
|
||||||
|
|
||||||
|
HOST_FRONTEND=192.168.0.94:5173
|
||||||
|
VITE_PORT_FRONTEND=5173
|
||||||
|
VITE_PORT_FRONTEND_HMR=5173
|
||||||
|
|
||||||
|
VITE_APP_NAME="Classroom Copilot"
|
||||||
|
VITE_SUPER_ADMIN_EMAIL=[email protected]
|
||||||
|
VITE_DEV=true
|
||||||
|
VITE_FRONTEND_SITE_URL=http://192.168.0.94:5173
|
||||||
|
VITE_APP_HMR_URL=http://192.168.0.94:5173
|
||||||
|
|
||||||
|
# Supabase is on external container - use its IP
|
||||||
|
VITE_SUPABASE_URL=http://192.168.0.155:8000
|
||||||
|
|
||||||
|
# API should use localhost for local development
|
||||||
|
VITE_API_URL=http://192.168.0.94:8000
|
||||||
|
VITE_API_BASE=http://192.168.0.94:8000
|
||||||
|
|
||||||
|
# Neo4j
|
||||||
|
VITE_NEO4J_URL=bolt://192.168.0.208:7687
|
||||||
|
VITE_NEO4J_USER=neo4j
|
||||||
|
VITE_NEO4J_PASSWORD=kevlarai
|
||||||
|
|
||||||
|
# LLM
|
||||||
|
VITE_LLM_PROVIDER=ollama
|
||||||
|
VITE_OLLAMA_API_URL=https://ollama.kevlarai.com
|
||||||
|
|
||||||
|
# Microsoft
|
||||||
|
VITE_MICROSOFT_CLIENT_ID=dummy_client_id
|
||||||
|
VITE_MICROSOFT_CLIENT_SECRET_DESC="Microsoft OAuth client secret"
|
||||||
|
VITE_MICROSOFT_CLIENT_SECRET_ID=dummy_secret_id
|
||||||
|
VITE_MICROSOFT_CLIENT_SECRET=dummy_secret
|
||||||
|
VITE_MICROSOFT_TENANT_ID=common
|
||||||
|
|
||||||
|
VITE_SEARCH_URL=https://search.kevlarai.com
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Classroom Copilot App - Environment Variables Template
|
||||||
|
# Copy this file to .env and fill in the values
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Frontend Configuration
|
||||||
|
# =============================================================================
|
||||||
|
VITE_APP_NAME=Classroom Copilot
|
||||||
|
VITE_DEV=false
|
||||||
|
VITE_FRONTEND_SITE_URL=https://app.classroomcopilot.ai
|
||||||
|
VITE_PORT_FRONTEND=3000
|
||||||
|
VITE_PORT_FRONTEND_HMR=24678
|
||||||
|
VITE_APP_HMR_URL=http://localhost:24678
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Configuration
|
||||||
|
# =============================================================================
|
||||||
|
VITE_API_URL=https://api.classroomcopilot.ai
|
||||||
|
VITE_API_BASE=https://api.classroomcopilot.ai
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Supabase Configuration
|
||||||
|
# =============================================================================
|
||||||
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TLSync (TLDraw Sync) Configuration — shared secret is server-side only (API TLSYNC_SECRET)
|
||||||
|
# =============================================================================
|
||||||
|
VITE_TLSYNC_URL=https://app.classroomcopilot.ai/tldraw
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WhisperLive (Transcription) Configuration
|
||||||
|
# =============================================================================
|
||||||
|
VITE_WHISPERLIVE_URL=wss://whisperlive.classroomcopilot.ai/ws
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Search Configuration
|
||||||
|
# =============================================================================
|
||||||
|
VITE_SEARCH_URL=https://search.kevlarai.com
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Super Admin Configuration
|
||||||
|
# =============================================================================
|
||||||
|
VITE_SUPER_ADMIN_EMAIL=[email protected]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Port Configuration (for docker-compose)
|
||||||
|
# =============================================================================
|
||||||
|
PORT_FRONTEND=3000
|
||||||
|
PORT_FRONTEND_HMR=24678
|
||||||
|
PORT_API=8000
|
||||||
|
PORT_SUPABASE=8000
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
PORT_FRONTEND=5173
|
|
||||||
PORT_FRONTEND_HMR=3002
|
|
||||||
PORT_API=800
|
|
||||||
PORT_SUPABASE=8000
|
|
||||||
|
|
||||||
VITE_PORT_FRONTEND=5173
|
|
||||||
VITE_PORT_FRONTEND_HMR=5173
|
|
||||||
|
|
||||||
VITE_APP_NAME=Classroom Copilot
|
|
||||||
VITE_SUPER_ADMIN_EMAIL=[email protected]
|
|
||||||
VITE_DEV=true
|
|
||||||
VITE_FRONTEND_SITE_URL=https://app.classroomcopilot.ai
|
|
||||||
VITE_APP_HMR_URL=https://app.classroomcopilot.ai
|
|
||||||
VITE_SUPABASE_URL=https://supa.classroomcopilot.ai
|
|
||||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
|
||||||
VITE_API_URL=https://api.classroomcopilot.ai
|
|
||||||
VITE_API_BASE=https://api.classroomcopilot.ai
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: app-ci-deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-build-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:run
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Deploy app
|
||||||
|
run: |
|
||||||
|
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.APP_DEPLOY_HOST }}" '
|
||||||
|
set -euo pipefail
|
||||||
|
cd /home/kcar/app
|
||||||
|
git fetch origin master
|
||||||
|
git reset --hard origin/master
|
||||||
|
docker network inspect kevlarai-network >/dev/null 2>&1 || docker network create kevlarai-network
|
||||||
|
docker compose -p app-prod -f docker-compose.yml up -d --build
|
||||||
|
docker compose -p app-prod -f docker-compose.yml ps
|
||||||
|
curl -fsSI http://127.0.0.1:3000 >/dev/null
|
||||||
|
'
|
||||||
+50
-1
@@ -1,3 +1,52 @@
|
|||||||
node_modules
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
/dist
|
||||||
|
/build
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment files (never commit secrets)
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env.bak
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.cache/
|
||||||
|
.tmp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Local environment variants
|
||||||
|
.env.dev
|
||||||
|
.env.prod
|
||||||
|
|
||||||
|
# Playwright test artifacts
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, it } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { WrappedApp, App } from '../src/App';
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('Renders hello world', () => {
|
|
||||||
// ARRANGE
|
|
||||||
render(<WrappedApp />);
|
|
||||||
// ACT
|
|
||||||
// EXPECT
|
|
||||||
expect(
|
|
||||||
screen.getByRole('heading', {
|
|
||||||
level: 1,
|
|
||||||
}
|
|
||||||
)).toHaveTextContent('Hello World')
|
|
||||||
});
|
|
||||||
it('Renders not found if invalid path', () => {
|
|
||||||
// ARRANGE
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/this-route-does-not-exist']}>
|
|
||||||
<App />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
// ACT
|
|
||||||
// EXPECT
|
|
||||||
expect(
|
|
||||||
screen.getByRole('heading', {
|
|
||||||
level: 1,
|
|
||||||
}
|
|
||||||
)).toHaveTextContent('Not Found')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+24
-10
@@ -1,12 +1,17 @@
|
|||||||
FROM node:20 as builder
|
FROM node:20 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
# TODO: Remove this or review embedded variables
|
|
||||||
COPY .env .env
|
|
||||||
|
|
||||||
# First generate package-lock.json if it doesn't exist, then do clean install
|
# First generate package-lock.json if it doesn't exist, then do clean install
|
||||||
RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && npm ci
|
RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Vite bakes VITE_* values at build time, so compose must choose the env file
|
||||||
|
# during image build, not only at container runtime.
|
||||||
|
ARG ENV_FILE=.env
|
||||||
|
COPY ${ENV_FILE} .env
|
||||||
|
|
||||||
# Run build with production mode
|
# Run build with production mode
|
||||||
RUN npm run build -- --mode production
|
RUN npm run build -- --mode production
|
||||||
|
|
||||||
@@ -19,20 +24,29 @@ RUN echo 'server { \
|
|||||||
listen 3000; \
|
listen 3000; \
|
||||||
root /usr/share/nginx/html; \
|
root /usr/share/nginx/html; \
|
||||||
index index.html; \
|
index index.html; \
|
||||||
|
location = /index.html { \
|
||||||
|
expires -1; \
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
||||||
|
} \
|
||||||
location / { \
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
expires 30d; \
|
expires 30d; \
|
||||||
add_header Cache-Control "public, no-transform"; \
|
add_header Cache-Control "public, no-transform"; \
|
||||||
} \
|
} \
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { \
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { \
|
||||||
expires 30d; \
|
expires 30d; \
|
||||||
add_header Cache-Control "public, no-transform"; \
|
add_header Cache-Control "public, no-transform"; \
|
||||||
|
} \
|
||||||
|
location /searxng-api/ { \
|
||||||
|
proxy_pass https://search.kevlarai.com/; \
|
||||||
|
proxy_ssl_server_name on; \
|
||||||
|
proxy_set_header Host search.kevlarai.com; \
|
||||||
} \
|
} \
|
||||||
location ~ /\. { \
|
location ~ /\. { \
|
||||||
deny all; \
|
deny all; \
|
||||||
} \
|
} \
|
||||||
error_page 404 /index.html; \
|
error_page 404 /index.html; \
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Set up permissions
|
# Set up permissions
|
||||||
RUN chown -R nginx:nginx /usr/share/nginx/html \
|
RUN chown -R nginx:nginx /usr/share/nginx/html \
|
||||||
|
|||||||
Vendored
-59
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"_vendor-mui.js": {
|
|
||||||
"file": "assets/vendor-mui.js",
|
|
||||||
"name": "vendor-mui",
|
|
||||||
"imports": [
|
|
||||||
"_vendor-react.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"_vendor-react.js": {
|
|
||||||
"file": "assets/vendor-react.js",
|
|
||||||
"name": "vendor-react"
|
|
||||||
},
|
|
||||||
"_vendor-tldraw.js": {
|
|
||||||
"file": "assets/vendor-tldraw.js",
|
|
||||||
"name": "vendor-tldraw",
|
|
||||||
"imports": [
|
|
||||||
"_vendor-react.js",
|
|
||||||
"_vendor-mui.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"_vendor-utils.js": {
|
|
||||||
"file": "assets/vendor-utils.js",
|
|
||||||
"name": "vendor-utils",
|
|
||||||
"imports": [
|
|
||||||
"_vendor-react.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"index.html": {
|
|
||||||
"file": "assets/index-CmYeIoD0.js",
|
|
||||||
"name": "index",
|
|
||||||
"src": "index.html",
|
|
||||||
"isEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"_vendor-mui.js",
|
|
||||||
"_vendor-react.js",
|
|
||||||
"_vendor-tldraw.js",
|
|
||||||
"_vendor-utils.js"
|
|
||||||
],
|
|
||||||
"dynamicImports": [
|
|
||||||
"node_modules/pdfjs-dist/build/pdf.mjs"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/index.css"
|
|
||||||
],
|
|
||||||
"assets": [
|
|
||||||
"assets/pdf.worker.min.mjs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/pdfjs-dist/build/pdf.mjs": {
|
|
||||||
"file": "assets/pdf.js",
|
|
||||||
"name": "pdf",
|
|
||||||
"src": "node_modules/pdfjs-dist/build/pdf.mjs",
|
|
||||||
"isDynamicEntry": true
|
|
||||||
},
|
|
||||||
"node_modules/pdfjs-dist/build/pdf.worker.min.mjs": {
|
|
||||||
"file": "assets/pdf.worker.min.mjs",
|
|
||||||
"src": "node_modules/pdfjs-dist/build/pdf.worker.min.mjs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-68827
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-11178
File diff suppressed because it is too large
Load Diff
Vendored
-21232
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-21
File diff suppressed because one or more lines are too long
Vendored
-19850
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1901
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-68796
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-12264
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-12
@@ -1,12 +0,0 @@
|
|||||||
class AudioProcessor extends AudioWorkletProcessor {
|
|
||||||
process(inputs) {
|
|
||||||
const input = inputs[0];
|
|
||||||
if (input.length > 0) {
|
|
||||||
const audioData = input[0];
|
|
||||||
this.port.postMessage(audioData);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerProcessor('audio-processor', AudioProcessor);
|
|
||||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 259 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 259 KiB |
Vendored
-21
@@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- Sticker outline -->
|
|
||||||
<path d="M20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<!-- Peeling corner effect -->
|
|
||||||
<path d="M16 8C16 10.2091 14.2091 12 12 12"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<!-- Star decoration -->
|
|
||||||
<path d="M12 8L13 9L12 10L11 9L12 8Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 689 B |
Vendored
-18
@@ -1,18 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Classroom Copilot</title>
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
<script type="module" crossorigin src="/assets/index-CmYeIoD0.js"></script>
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-react.js">
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-mui.js">
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-tldraw.js">
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-utils.js">
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index.css">
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"name":"ClassroomCopilot","short_name":"CC","start_url":"/","display":"fullscreen","background_color":"#ffffff","theme_color":"#000000","lang":"en","scope":"/","icons":[{"src":"/icons/icon-192x192.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"/icons/icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"any"},{"src":"/icons/icon-192x192-maskable.png","sizes":"192x192","type":"image/png","purpose":"maskable"},{"src":"/icons/icon-512x512-maskable.png","sizes":"512x512","type":"image/png","purpose":"maskable"}]}
|
|
||||||
Vendored
-70
@@ -1,70 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Offline - Classroom Copilot</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
|
||||||
padding: 40px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #1a73e8;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.retry-button {
|
|
||||||
background-color: #1a73e8;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
.retry-button:hover {
|
|
||||||
background-color: #1557b0;
|
|
||||||
}
|
|
||||||
.icon {
|
|
||||||
font-size: 64px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="icon">📡</div>
|
|
||||||
<h1>You're Offline</h1>
|
|
||||||
<p>It looks like you've lost your internet connection. Don't worry - any work you've done has been saved locally.</p>
|
|
||||||
<p>Please check your connection and try again.</p>
|
|
||||||
<button class="retry-button" onclick="window.location.reload()">Try Again</button>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
// Check if we're back online
|
|
||||||
window.addEventListener('online', () => {
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}
|
|
||||||
Vendored
-3889
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
frontend-test:
|
||||||
|
image: node:20-bookworm
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- app-test-node-modules:/app/node_modules
|
||||||
|
command: sh -lc "npm ci && npm run test:run"
|
||||||
|
profiles:
|
||||||
|
- test
|
||||||
|
|
||||||
|
frontend-dev:
|
||||||
|
container_name: cc-app-dev
|
||||||
|
image: cc-app-dev:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
ENV_FILE: .env.dev
|
||||||
|
env_file:
|
||||||
|
- .env.dev
|
||||||
|
ports:
|
||||||
|
- "13000:3000"
|
||||||
|
networks:
|
||||||
|
- kevlarai-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-test-node-modules:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kevlarai-network:
|
||||||
|
external: true
|
||||||
|
name: kevlarai-network
|
||||||
+8
-6
@@ -1,19 +1,21 @@
|
|||||||
services:
|
services:
|
||||||
classroom-copilot:
|
frontend:
|
||||||
container_name: classroom-copilot
|
container_name: cc-prod
|
||||||
|
image: cc-app-prod:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
ENV_FILE: .env
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- "3000:3000"
|
||||||
volumes:
|
|
||||||
- ./:/app
|
|
||||||
networks:
|
networks:
|
||||||
- kevlarai-network
|
- kevlarai-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
kevlarai-network:
|
kevlarai-network:
|
||||||
|
external: true
|
||||||
name: kevlarai-network
|
name: kevlarai-network
|
||||||
driver: bridge
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# Timetable Feature Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Timetable module provides comprehensive class management and scheduling capabilities for Classroom Copilot. It enables teachers to create and manage classes, students to enroll and view their schedules, and real-time lesson management with TLDraw whiteboard integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. My Timetable
|
||||||
|
- **Route**: `/timetable`
|
||||||
|
- **Description**: Personal calendar view showing all enrolled classes
|
||||||
|
- **Components**:
|
||||||
|
- Week/Month view toggle
|
||||||
|
- Lesson cards with time, subject, and location
|
||||||
|
- Quick access to join ongoing lessons
|
||||||
|
- Color-coded subjects
|
||||||
|
|
||||||
|
### 2. Browse Classes
|
||||||
|
- **Route**: `/classes`
|
||||||
|
- **Description**: Discovery page for finding available classes
|
||||||
|
- **Components**:
|
||||||
|
- Search and filter (by subject, teacher, schedule)
|
||||||
|
- Class cards with enrollment status
|
||||||
|
- One-click enrollment request
|
||||||
|
- Class details modal
|
||||||
|
|
||||||
|
### 3. My Classes
|
||||||
|
- **Route**: `/my-classes`
|
||||||
|
- **Description**: Management dashboard for enrolled/teaching classes
|
||||||
|
- **Teacher View**:
|
||||||
|
- Classes they teach
|
||||||
|
- Enrollment requests pending approval
|
||||||
|
- Quick links to edit class details
|
||||||
|
- Lesson management controls
|
||||||
|
- **Student View**:
|
||||||
|
- Enrolled classes list
|
||||||
|
- Progress indicators
|
||||||
|
- Assignment due dates
|
||||||
|
- Teacher contact info
|
||||||
|
|
||||||
|
### 4. Enrollment Requests
|
||||||
|
- **Route**: `/enrollment-requests`
|
||||||
|
- **Description**: Teacher approval interface for class enrollments
|
||||||
|
- **Components**:
|
||||||
|
- Pending requests list
|
||||||
|
- Student information preview
|
||||||
|
- Approve/Reject actions
|
||||||
|
- Bulk approval options
|
||||||
|
- Request history
|
||||||
|
|
||||||
|
### 5. Lesson View
|
||||||
|
- **Route**: `/lessons/:lessonId`
|
||||||
|
- **Description**: Individual lesson page with TLDraw whiteboard
|
||||||
|
- **Components**:
|
||||||
|
- Real-time collaborative whiteboard
|
||||||
|
- Lesson info sidebar
|
||||||
|
- Student roster
|
||||||
|
- Join/Leave controls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes Configuration
|
||||||
|
|
||||||
|
### AppRoutes.tsx
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
element: <FullContextRoutes />,
|
||||||
|
children: [
|
||||||
|
// ... other routes ...
|
||||||
|
{
|
||||||
|
path: "/timetable",
|
||||||
|
element: <TimetablePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/classes",
|
||||||
|
element: <ClassesPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/my-classes",
|
||||||
|
element: <MyClassesPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/enrollment-requests",
|
||||||
|
element: <EnrollmentRequestsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/lessons/:lessonId",
|
||||||
|
element: <LessonPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation (Header.tsx)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
label: "Timetable",
|
||||||
|
path: "/timetable",
|
||||||
|
icon: <CalendarMonthIcon />,
|
||||||
|
roles: ["teacher", "student"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Browse Classes",
|
||||||
|
path: "/classes",
|
||||||
|
icon: <SchoolIcon />,
|
||||||
|
roles: ["teacher", "student"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "My Classes",
|
||||||
|
path: "/my-classes",
|
||||||
|
icon: <ClassIcon />,
|
||||||
|
roles: ["teacher", "student"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Enrollment Requests",
|
||||||
|
path: "/enrollment-requests",
|
||||||
|
icon: <PendingIcon />,
|
||||||
|
roles: ["teacher"], // Teacher only
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── timetable/
|
||||||
|
│ │ ├── CalendarView.tsx # Week/Month calendar display
|
||||||
|
│ │ ├── LessonCard.tsx # Individual lesson card
|
||||||
|
│ │ ├── ClassCard.tsx # Class listing card
|
||||||
|
│ │ ├── EnrollmentRequestCard.tsx # Pending request item
|
||||||
|
│ │ ├── CreateClassModal.tsx # Class creation form
|
||||||
|
│ │ └── LessonWhiteboard.tsx # TLDraw integration
|
||||||
|
│ └── navigation/
|
||||||
|
│ └── Header.tsx # Navigation with timetable items
|
||||||
|
├── pages/
|
||||||
|
│ ├── TimetablePage.tsx # Main calendar view
|
||||||
|
│ ├── ClassesPage.tsx # Browse available classes
|
||||||
|
│ ├── MyClassesPage.tsx # Enrolled/teaching classes
|
||||||
|
│ ├── EnrollmentRequestsPage.tsx # Teacher approval interface
|
||||||
|
│ └── LessonPage.tsx # Individual lesson with whiteboard
|
||||||
|
├── services/
|
||||||
|
│ └── timetable/
|
||||||
|
│ ├── classes.service.ts # Class CRUD operations
|
||||||
|
│ ├── timetable.service.ts # Schedule management
|
||||||
|
│ └── lessons.service.ts # Lesson operations
|
||||||
|
├── stores/
|
||||||
|
│ └── timetableStore.ts # Zustand state management
|
||||||
|
└── types/
|
||||||
|
└── timetable.ts # TypeScript interfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Required Packages
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@mui/icons-material": "^5.x",
|
||||||
|
"@mui/material": "^5.x",
|
||||||
|
"zustand": "^4.x",
|
||||||
|
"react-router-dom": "^6.x",
|
||||||
|
"axios": "^1.x",
|
||||||
|
"date-fns": "^2.x",
|
||||||
|
"@tldraw/tldraw": "^2.x"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
# API Base URL
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# Supabase (for auth)
|
||||||
|
VITE_SUPABASE_URL=http://192.168.0.155:8000
|
||||||
|
VITE_SUPABASE_ANON_KEY=<your-anon-key>
|
||||||
|
|
||||||
|
# TLDraw Sync Server
|
||||||
|
VITE_TLDRAW_SYNC_URL=ws://192.168.0.145:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Navigate to Timetable
|
||||||
|
```typescript
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
navigate('/timetable');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch Classes
|
||||||
|
```typescript
|
||||||
|
import { useTimetableStore } from '../stores/timetableStore';
|
||||||
|
|
||||||
|
const { classes, fetchClasses } = useTimetableStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClasses();
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enroll in Class
|
||||||
|
```typescript
|
||||||
|
const { enrollInClass } = useTimetableStore();
|
||||||
|
|
||||||
|
await enrollInClass('class-uuid-here');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
### Running Locally
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
cd /a0/usr/projects/classroom_copilot_app
|
||||||
|
./scripts/dev-start.sh
|
||||||
|
|
||||||
|
# Frontend only
|
||||||
|
cd frontend
|
||||||
|
npm run dev -- --host 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Endpoints
|
||||||
|
```bash
|
||||||
|
# Run comprehensive tests
|
||||||
|
python scripts/test_timetable_endpoints.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Demo Credentials
|
||||||
|
| Role | Email | Password |
|
||||||
|
|------|-------|----------|
|
||||||
|
| Teacher | teacher1@kevlarai.edu | DemoTeacher123! |
|
||||||
|
| Student | student1@kevlarai.edu | DemoStudent123! |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Backend API Spec](../planning/phase1_timetable_lessons/02_BACKEND_API_SPEC.md)
|
||||||
|
- [Frontend Architecture](../planning/phase1_timetable_lessons/03_FRONTEND_ARCHITECTURE.md)
|
||||||
|
- [TLDraw Sync Spec](../planning/phase1_timetable_lessons/05_WEBSOCKET_SYNC_SPEC.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 2026-02-26
|
||||||
|
- ✅ Added Header navigation for timetable features
|
||||||
|
- ✅ Integrated timetable routes in AppRoutes.tsx
|
||||||
|
- ✅ Created documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-02-26
|
||||||
Generated
+2200
-4328
File diff suppressed because it is too large
Load Diff
+3
-11
@@ -5,7 +5,9 @@
|
|||||||
"start": "vite --host",
|
"start": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
@@ -57,14 +59,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.15.0",
|
"@eslint/js": "^9.15.0",
|
||||||
"@storybook/addon-actions": "^8.6.12",
|
|
||||||
"@storybook/addon-essentials": "^8.6.12",
|
|
||||||
"@storybook/addon-interactions": "^8.6.12",
|
|
||||||
"@storybook/addon-links": "^8.6.12",
|
|
||||||
"@storybook/addon-onboarding": "^8.6.12",
|
|
||||||
"@storybook/react": "^8.6.12",
|
|
||||||
"@storybook/react-vite": "^8.6.12",
|
|
||||||
"@storybook/testing-library": "^0.2.2",
|
|
||||||
"@testing-library/jest-dom": "^6.4.5",
|
"@testing-library/jest-dom": "^6.4.5",
|
||||||
"@testing-library/react": "^15.0.7",
|
"@testing-library/react": "^15.0.7",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
@@ -90,8 +84,6 @@
|
|||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"react-refresh": "^0.14.2",
|
"react-refresh": "^0.14.2",
|
||||||
"storybook": "^8.6.12",
|
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './src/__tests__/e2e',
|
||||||
|
testMatch: '**/*.spec.js',
|
||||||
|
timeout: 30000,
|
||||||
|
expect: { timeout: 5000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 1,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
webServer: undefined,
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
'postcss-import': {},
|
'postcss-import': {},
|
||||||
},
|
},
|
||||||
|
|||||||
+16
-2
@@ -1,9 +1,23 @@
|
|||||||
class AudioProcessor extends AudioWorkletProcessor {
|
class AudioProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._buffer = new Float32Array(4096);
|
||||||
|
this._bufferIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
process(inputs) {
|
process(inputs) {
|
||||||
const input = inputs[0];
|
const input = inputs[0];
|
||||||
if (input.length > 0) {
|
if (input.length > 0) {
|
||||||
const audioData = input[0];
|
const samples = input[0];
|
||||||
this.port.postMessage(audioData);
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
this._buffer[this._bufferIndex++] = samples[i];
|
||||||
|
if (this._bufferIndex >= 4096) {
|
||||||
|
// Transfer ownership (zero-copy) to main thread
|
||||||
|
this.port.postMessage(this._buffer.buffer, [this._buffer.buffer]);
|
||||||
|
this._buffer = new Float32Array(4096);
|
||||||
|
this._bufferIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-21
@@ -1,34 +1,70 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { useMemo, useEffect } from 'react';
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import { useTLDraw } from './contexts/TLDrawContext';
|
||||||
import { theme } from './services/themeService';
|
import { theme } from './services/themeService';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { TLDrawProvider } from './contexts/TLDrawContext';
|
import { TLDrawProvider } from './contexts/TLDrawContext';
|
||||||
import { UserProvider } from './contexts/UserContext';
|
import { UserProvider } from './contexts/UserContext';
|
||||||
import { NeoUserProvider } from './contexts/NeoUserContext';
|
|
||||||
import { NeoInstituteProvider } from './contexts/NeoInstituteContext';
|
|
||||||
import AppRoutes from './AppRoutes';
|
import AppRoutes from './AppRoutes';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
// Wrap the entire app in a memo to prevent unnecessary re-renders
|
/**
|
||||||
const App = React.memo(() => (
|
* Syncs tldraw's color scheme to data-color-mode on <html>.
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
* Must live inside TLDrawProvider so useTLDraw() reads live preferences.
|
||||||
<ThemeProvider theme={theme}>
|
* This activates the [data-color-mode="dark"] overrides in cc-design-system.css.
|
||||||
<AuthProvider>
|
* tldraw itself uses tl-theme__dark class on .tl-container — we mirror that
|
||||||
<UserProvider>
|
* to the document root so --cc-* token overrides apply globally.
|
||||||
<NeoUserProvider>
|
*/
|
||||||
<NeoInstituteProvider>
|
const DarkModeSync: React.FC = () => {
|
||||||
<TLDrawProvider>
|
const { tldrawPreferences } = useTLDraw();
|
||||||
|
const prefersDarkMode =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scheme = tldrawPreferences?.colorScheme ?? 'system';
|
||||||
|
const isDark = scheme === 'dark' || (scheme === 'system' && prefersDarkMode);
|
||||||
|
document.documentElement.setAttribute('data-color-mode', isDark ? 'dark' : 'light');
|
||||||
|
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = React.memo(() => {
|
||||||
|
const prefersDarkMode =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
const appTheme = useMemo(() => {
|
||||||
|
// App is outside TLDrawProvider so tldrawPreferences is always null here.
|
||||||
|
// Theme defaults to OS preference; DarkModeSync (inside TLDrawProvider) handles
|
||||||
|
// updating data-color-mode once tldraw preferences are loaded.
|
||||||
|
const mode = prefersDarkMode ? 'dark' : 'light';
|
||||||
|
return createTheme({ palette: { mode } });
|
||||||
|
}, [prefersDarkMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<AuthProvider>
|
||||||
|
<UserProvider>
|
||||||
|
<TLDrawProvider>
|
||||||
|
<DarkModeSync />
|
||||||
|
<ThemeProvider theme={appTheme}>
|
||||||
|
<CssBaseline />
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</TLDrawProvider>
|
</ThemeProvider>
|
||||||
</NeoInstituteProvider>
|
</TLDrawProvider>
|
||||||
</NeoUserProvider>
|
</UserProvider>
|
||||||
</UserProvider>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ErrorBoundary>
|
||||||
</BrowserRouter>
|
);
|
||||||
));
|
});
|
||||||
|
|
||||||
// Add display name for better debugging
|
|
||||||
App.displayName = import.meta.env.VITE_APP_NAME;
|
App.displayName = import.meta.env.VITE_APP_NAME;
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import AppRoutes from './AppRoutes';
|
||||||
|
|
||||||
|
const mockUseAuth = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('./contexts/AuthContext', () => ({
|
||||||
|
useAuth: () => mockUseAuth(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./contexts/UserContext', () => ({
|
||||||
|
useUser: () => ({ isInitialized: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./debugConfig', () => ({
|
||||||
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/Layout', () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
vi.mock('./pages/auth/PlatformAdminPage', () => ({ default: () => <div>Platform Admin Page</div> }));
|
||||||
|
vi.mock('./pages/auth/adminPage', () => ({ default: () => <div>Legacy Admin</div> }));
|
||||||
|
vi.mock('./pages/auth/loginPage', () => ({ default: () => <div>Login Page</div> }));
|
||||||
|
vi.mock('./pages/auth/signupPage', () => ({ default: () => <div>Signup Page</div> }));
|
||||||
|
vi.mock('./pages/user/dashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
|
||||||
|
vi.mock('./pages/user/NotFound', () => ({ default: () => <div>Private Not Found</div> }));
|
||||||
|
vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found</div> }));
|
||||||
|
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
||||||
|
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
||||||
|
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
||||||
|
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
|
||||||
|
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||||
|
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||||
|
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||||
|
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
||||||
|
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
||||||
|
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
||||||
|
vi.mock('./pages/tldraw/ShareHandler', () => ({ default: () => <div>Share</div> }));
|
||||||
|
vi.mock('./pages/searxngPage', () => ({ default: () => <div>Search</div> }));
|
||||||
|
vi.mock('./pages/dev/SimpleUploadTest', () => ({ default: () => <div>Upload Test</div> }));
|
||||||
|
vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
|
||||||
|
CCDocumentIntelligence: () => <div>Doc Intelligence</div>,
|
||||||
|
}));
|
||||||
|
vi.mock('./pages/timetable', () => ({
|
||||||
|
TimetablePage: () => <div>Timetable</div>,
|
||||||
|
TimetableListPage: () => <div>Timetable List</div>,
|
||||||
|
ClassesPage: () => <div>Classes</div>,
|
||||||
|
LessonPage: () => <div>Lesson</div>,
|
||||||
|
TaughtLessonsPage: () => <div>Taught Lessons</div>,
|
||||||
|
MyClassesPage: () => <div>My Classes</div>,
|
||||||
|
EnrollmentRequestsPage: () => <div>Enrollment Requests</div>,
|
||||||
|
StaffManagerPage: () => <div>Staff Manager</div>,
|
||||||
|
StudentManagerPage: () => <div>Student Manager</div>,
|
||||||
|
SchoolSettingsPage: () => <div>School Settings</div>,
|
||||||
|
ClassDetailPage: () => <div>Class Detail</div>,
|
||||||
|
StudentLessonsPage: () => <div>Student Lessons</div>,
|
||||||
|
LessonPlansPage: () => <div>Lesson Plans</div>,
|
||||||
|
LessonPlanDetailPage: () => <div>Lesson Plan Detail</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<AppRoutes />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authState(overrides: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
user_role: null,
|
||||||
|
accessToken: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
signIn: vi.fn(),
|
||||||
|
signOut: vi.fn(),
|
||||||
|
clearError: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('/admin route authorization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseAuth.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects anonymous users away from /admin', () => {
|
||||||
|
mockUseAuth.mockReturnValue(authState({ user: null, user_role: null }));
|
||||||
|
|
||||||
|
renderAt('/admin');
|
||||||
|
|
||||||
|
expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects authenticated non-admin users away from /admin', () => {
|
||||||
|
mockUseAuth.mockReturnValue(authState({
|
||||||
|
user: { id: 'user-1', email: '[email protected]', user_type: 'email_teacher' },
|
||||||
|
user_role: 'email_teacher',
|
||||||
|
accessToken: 'token',
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderAt('/admin');
|
||||||
|
|
||||||
|
expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Dashboard Page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows super_admin users to access /admin', () => {
|
||||||
|
mockUseAuth.mockReturnValue(authState({
|
||||||
|
user: { id: 'admin-1', email: '[email protected]', user_type: 'super_admin' },
|
||||||
|
user_role: 'super_admin',
|
||||||
|
accessToken: 'token',
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderAt('/admin');
|
||||||
|
|
||||||
|
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
+80
-20
@@ -2,18 +2,18 @@ import React from 'react';
|
|||||||
import { Routes, Route, useLocation, Outlet, Navigate } from 'react-router-dom';
|
import { Routes, Route, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import { useUser } from './contexts/UserContext';
|
import { useUser } from './contexts/UserContext';
|
||||||
import { useNeoUser } from './contexts/NeoUserContext';
|
|
||||||
import { useNeoInstitute } from './contexts/NeoInstituteContext';
|
|
||||||
import Layout from './pages/Layout';
|
import Layout from './pages/Layout';
|
||||||
import LoginPage from './pages/auth/loginPage';
|
import LoginPage from './pages/auth/loginPage';
|
||||||
import SignupPage from './pages/auth/signupPage';
|
import SignupPage from './pages/auth/signupPage';
|
||||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||||
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
|
import { ExamDashboardPage, ExamTemplateSetupPage } from './pages/exam';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import CalendarPage from './pages/user/calendarPage';
|
import CalendarPage from './pages/user/calendarPage';
|
||||||
import SettingsPage from './pages/user/settingsPage';
|
import SettingsPage from './pages/user/settingsPage';
|
||||||
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
||||||
import AdminDashboard from './pages/auth/adminPage';
|
import AdminDashboard from './pages/auth/adminPage';
|
||||||
|
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
||||||
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
||||||
import DevPage from './pages/tldraw/devPage';
|
import DevPage from './pages/tldraw/devPage';
|
||||||
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
||||||
@@ -28,19 +28,28 @@ import { CircularProgress } from '@mui/material';
|
|||||||
import { CCDocumentIntelligence } from './pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence';
|
import { CCDocumentIntelligence } from './pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence';
|
||||||
import DashboardPage from './pages/user/dashboardPage';
|
import DashboardPage from './pages/user/dashboardPage';
|
||||||
|
|
||||||
|
// Timetable Module Pages
|
||||||
|
import {
|
||||||
|
TimetablePage,
|
||||||
|
TimetableListPage,
|
||||||
|
ClassesPage,
|
||||||
|
LessonPage,
|
||||||
|
TaughtLessonsPage,
|
||||||
|
MyClassesPage,
|
||||||
|
EnrollmentRequestsPage,
|
||||||
|
StaffManagerPage,
|
||||||
|
StudentManagerPage,
|
||||||
|
SchoolSettingsPage,
|
||||||
|
ClassDetailPage,
|
||||||
|
StudentLessonsPage,
|
||||||
|
LessonPlansPage,
|
||||||
|
LessonPlanDetailPage,
|
||||||
|
} from './pages/timetable';
|
||||||
|
|
||||||
const FullContextRoutes: React.FC = () => {
|
const FullContextRoutes: React.FC = () => {
|
||||||
const { isInitialized: isUserInitialized } = useUser();
|
const { isInitialized: isUserInitialized } = useUser();
|
||||||
const { isLoading: isNeoUserLoading, isInitialized: isNeoUserInitialized } = useNeoUser();
|
|
||||||
const { isLoading: isNeoInstituteLoading, isInitialized: isNeoInstituteInitialized } = useNeoInstitute();
|
|
||||||
|
|
||||||
const isLoading =
|
if (!isUserInitialized) {
|
||||||
!isUserInitialized ||
|
|
||||||
isNeoUserLoading ||
|
|
||||||
!isNeoUserInitialized ||
|
|
||||||
isNeoInstituteLoading ||
|
|
||||||
!isNeoInstituteInitialized;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -59,9 +68,36 @@ const FullContextRoutes: React.FC = () => {
|
|||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RequireAuth: React.FC = () => {
|
||||||
|
const { user, isAuthResolving } = useAuth();
|
||||||
|
|
||||||
|
if (isAuthResolving) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
const AppRoutes: React.FC = () => {
|
const AppRoutes: React.FC = () => {
|
||||||
const { user, loading: isAuthLoading } = useAuth();
|
const { user, user_role, loading: isAuthLoading } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const platformAdminRoles = ['super_admin', 'cc_admin', 'cc_developer'];
|
||||||
|
const isPlatformAdmin = !!user && platformAdminRoles.includes(user_role ?? '');
|
||||||
|
|
||||||
// Debug log for routing
|
// Debug log for routing
|
||||||
logger.debug('routing', '🔄 Rendering routes', {
|
logger.debug('routing', '🔄 Rendering routes', {
|
||||||
@@ -114,16 +150,40 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
user?.user_type === 'admin' ? <AdminDashboard /> : <NotFound />
|
!user ? (
|
||||||
|
<Navigate to="/login" replace state={{ from: location }} />
|
||||||
|
) : isPlatformAdmin ? (
|
||||||
|
<PlatformAdminPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" replace />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||||
{user && (
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<FullContextRoutes />}>
|
<Route element={<FullContextRoutes />}>
|
||||||
|
{/* Timetable Module Routes */}
|
||||||
|
<Route path="/timetable" element={<TimetableListPage />} />
|
||||||
|
<Route path="/timetable/:timetableId" element={<TimetablePage />} />
|
||||||
|
<Route path="/classes" element={<ClassesPage />} />
|
||||||
|
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||||
|
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||||
|
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
||||||
|
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
||||||
|
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
||||||
|
<Route path="/enrollment-requests" element={<EnrollmentRequestsPage />} />
|
||||||
|
<Route path="/lessons/:lessonId" element={<LessonPage />} />
|
||||||
|
<Route path="/my-lessons" element={<TaughtLessonsPage />} />
|
||||||
|
<Route path="/staff-manager" element={<StaffManagerPage />} />
|
||||||
|
<Route path="/student-manager" element={<StudentManagerPage />} />
|
||||||
|
<Route path="/school-settings" element={<SchoolSettingsPage />} />
|
||||||
|
|
||||||
|
{/* Existing Routes */}
|
||||||
<Route path="/search" element={<SearxngPage />} />
|
<Route path="/search" element={<SearxngPage />} />
|
||||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||||
<Route path="/exam-marker" element={<CCExamMarker />} />
|
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||||
<Route path="/morphic" element={<MorphicPage />} />
|
<Route path="/morphic" element={<MorphicPage />} />
|
||||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||||
@@ -134,10 +194,10 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
)}
|
</Route>
|
||||||
|
|
||||||
{/* Fallback route - use different NotFound pages based on auth state */}
|
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
||||||
<Route path="*" element={user ? <NotFound /> : <NotFoundPublic />} />
|
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { test, expect, request } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
|
||||||
|
await ctx.dispose();
|
||||||
|
return Boolean(res);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.getByLabel('Email').fill(TEST_EMAIL);
|
||||||
|
await page.getByLabel('Password').fill(TEST_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipIfUnconfigured() {
|
||||||
|
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Canvas mount', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
skipIfUnconfigured();
|
||||||
|
const reachable = await serverReachable();
|
||||||
|
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login and show lesson canvas within 5s', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const canvasVisible = await page
|
||||||
|
.locator('[data-testid="tldraw-canvas"], .tl-container')
|
||||||
|
.waitFor({ timeout: 10000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
expect(canvasVisible).toBe(true);
|
||||||
|
await expect(page.locator('[class*="error-state"], [data-testid="error"]').first()).toBeHidden({ timeout: 2000 }).catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/// <reference types="@playwright/test" />
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
|
||||||
|
test.describe('Canvas mount', () => {
|
||||||
|
test('login and show lesson canvas', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
|
||||||
|
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: /sign in|log in/i }).click();
|
||||||
|
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
|
||||||
|
|
||||||
|
async function pingOk() {
|
||||||
|
try {
|
||||||
|
const r = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
|
||||||
|
() => null as any
|
||||||
|
);
|
||||||
|
return Boolean(r && (r as any).ok());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await pingOk())) test.skip(true, 'Dev server not reachable at ' + BASE);
|
||||||
|
|
||||||
|
const canvasVisible = page
|
||||||
|
.locator('[data-testid="tldraw-canvas"], .tl-container')
|
||||||
|
.waitFor({ timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
expect((await canvasVisible)).toBe(true);
|
||||||
|
await expect(page.getByText(/error|failed to load/i).first()).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect, request } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
|
||||||
|
await ctx.dispose();
|
||||||
|
return Boolean(res);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.getByLabel('Email').fill(TEST_EMAIL);
|
||||||
|
await page.getByLabel('Password').fill(TEST_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipIfUnconfigured() {
|
||||||
|
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Node navigation', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
skipIfUnconfigured();
|
||||||
|
const reachable = await serverReachable();
|
||||||
|
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canvas remounts cleanly on node navigation', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', err => errors.push(err.message));
|
||||||
|
await login(page);
|
||||||
|
await page.locator('[data-testid="tldraw-canvas"], .tl-container').waitFor({ timeout: 10000 });
|
||||||
|
const navLinks = page.locator('nav a, [data-testid="nav-node"]');
|
||||||
|
const count = await navLinks.count();
|
||||||
|
if (count > 1) {
|
||||||
|
await navLinks.nth(1).click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const hasCanvas = await page.locator('[data-testid="tldraw-canvas"], .tl-container')
|
||||||
|
.waitFor({ timeout: 8000 }).then(() => true).catch(() => false);
|
||||||
|
expect(hasCanvas).toBe(true);
|
||||||
|
}
|
||||||
|
const criticalErrors = errors.filter(e => /uncaught|undefined is not/i.test(e));
|
||||||
|
expect(criticalErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/// <reference types="@playwright/test" />
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
|
||||||
|
test.describe('Node navigation', () => {
|
||||||
|
test('canvas remounts on node change', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const res = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
|
||||||
|
() => null as any
|
||||||
|
);
|
||||||
|
return Boolean(res && (res as any).ok());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await serverReachable())) test.skip(true, 'Dev server not reachable at ' + BASE);
|
||||||
|
|
||||||
|
const consoleMessages: string[] = [];
|
||||||
|
page.on('console', (msg: any) => consoleMessages.push((msg.text() as string) || ''));
|
||||||
|
page.on('pageerror', (err: any) => consoleMessages.push('pageerror: ' + ((err.message as string) || 'unknown')));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
|
||||||
|
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: /sign in|log in/i }).click();
|
||||||
|
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
|
||||||
|
|
||||||
|
const canvas = page.locator('[data-testid="tldraw-canvas"], .tl-container');
|
||||||
|
if ((await canvas.count()) === 0) await canvas.first().waitFor({ timeout: 8000 });
|
||||||
|
await canvas.first().waitFor({ state: 'visible', timeout: 8000 });
|
||||||
|
|
||||||
|
const nodes = page.locator('a[href*="/node/"]').first();
|
||||||
|
if ((await nodes.count()) === 0) test.skip(true, 'No node navigation links found');
|
||||||
|
await nodes.click();
|
||||||
|
await page.waitForURL((url: URL) => /\/node\/\d+/.test(url.pathname), { timeout: 12000 });
|
||||||
|
|
||||||
|
await expect(canvas.first()).toBeVisible();
|
||||||
|
const errorTexts = consoleMessages.some((m) => /error|failed/i.test(m));
|
||||||
|
expect(errorTexts).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect, request } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
|
||||||
|
await ctx.dispose();
|
||||||
|
return Boolean(res);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.getByLabel('Email').fill(TEST_EMAIL);
|
||||||
|
await page.getByLabel('Password').fill(TEST_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipIfUnconfigured() {
|
||||||
|
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Transcription toggle', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
skipIfUnconfigured();
|
||||||
|
const reachable = await serverReachable();
|
||||||
|
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('record button toggles isRecording state', async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(['microphone']);
|
||||||
|
await login(page);
|
||||||
|
await page.locator('[data-testid="tldraw-canvas"], .tl-container').waitFor({ timeout: 10000 });
|
||||||
|
const recordBtn = page.locator('[data-testid="record-button"], button[aria-label*="ecord" i]').first();
|
||||||
|
const btnVisible = await recordBtn.waitFor({ timeout: 5000 }).then(() => true).catch(() => false);
|
||||||
|
if (btnVisible) {
|
||||||
|
const labelBefore = await recordBtn.getAttribute('aria-label').catch(() => '');
|
||||||
|
await recordBtn.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const labelAfter = await recordBtn.getAttribute('aria-label').catch(() => '');
|
||||||
|
expect(labelBefore).not.toEqual(labelAfter);
|
||||||
|
await recordBtn.click();
|
||||||
|
} else {
|
||||||
|
test.skip(true, 'Record button not visible on current node type');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/// <reference types="@playwright/test" />
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
|
||||||
|
test.describe('Transcription toggle', () => {
|
||||||
|
test('record and stop preserve segments', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const res = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
|
||||||
|
() => null as any
|
||||||
|
);
|
||||||
|
return Boolean(res && (res as any).ok());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await serverReachable())) test.skip(true, 'Dev server not reachable at ' + BASE);
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
|
||||||
|
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: /sign in|log in/i }).click();
|
||||||
|
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
|
||||||
|
|
||||||
|
const recordButton = page.locator('[data-testid="record-button"], button[aria-label*="record" i], button[aria-label*="Record" i]').first();
|
||||||
|
if ((await recordButton.count()) === 0) test.skip(true, 'No transcription record control found');
|
||||||
|
const beforeCount = await page.locator('[data-testid="segment"], .segment, .transcription-segment').count();
|
||||||
|
|
||||||
|
await recordButton.click();
|
||||||
|
await expect(recordButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await recordButton.click();
|
||||||
|
|
||||||
|
const afterCount = await page.locator('[data-testid="segment"], .segment, .transcription-segment').count();
|
||||||
|
expect(afterCount).toBeGreaterThanOrEqual(beforeCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-3
@@ -1,11 +1,12 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { API_BASE as CONFIG_API_BASE } from './config/apiConfig';
|
||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
|
|
||||||
// Use development backend URL if no custom URL is provided
|
// Use development backend URL if no custom URL is provided
|
||||||
const baseURL = import.meta.env.VITE_API_BASE || 'http://localhost:8080';
|
const baseURL = CONFIG_API_BASE;
|
||||||
|
|
||||||
if (!import.meta.env.VITE_API_BASE) {
|
if (!import.meta.env.VITE_API_BASE && !import.meta.env.VITE_API_URL) {
|
||||||
logger.warn('axios', '⚠️ VITE_API_BASE not set, defaulting to http://localhost:8080');
|
logger.warn('axios', '⚠️ VITE_API_BASE not set, using relative /api fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<h1 style={{ color: '#dc2626', marginBottom: '1rem' }}>Something went wrong</h1>
|
||||||
|
<p style={{ color: '#6b7280', marginBottom: '1.5rem' }}>
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload Application
|
||||||
|
</button>
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<details style={{ marginTop: '2rem', textAlign: 'left', maxWidth: '600px' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: '#6b7280' }}>Error Details</summary>
|
||||||
|
<pre style={{
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}>
|
||||||
|
{this.state.error?.stack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Close from '@mui/icons-material/Close';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
maxWidth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal panel */}
|
||||||
|
<div className={`relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full ${maxWidth}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 flex items-center justify-between border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<Close sx={{ fontSize: 20 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-4 py-5 sm:p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
@@ -1,458 +1,121 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
IconButton,
|
IconButton, Tooltip, Box, Menu, MenuItem,
|
||||||
Tooltip,
|
ListItemIcon, ListItemText, Chip, styled,
|
||||||
Box,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Button,
|
|
||||||
styled
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
ArrowBack as ArrowBackIcon,
|
ArrowBack as ArrowBackIcon,
|
||||||
ArrowForward as ArrowForwardIcon,
|
ArrowForward as ArrowForwardIcon,
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
School as SchoolIcon,
|
Home as HomeIcon,
|
||||||
Person as PersonIcon,
|
CalendarToday,
|
||||||
AccountCircle as AccountCircleIcon,
|
DateRange,
|
||||||
CalendarToday as CalendarIcon,
|
Event,
|
||||||
School as TeachingIcon,
|
WorkspacesOutlined,
|
||||||
Business as BusinessIcon,
|
|
||||||
AccountTree as DepartmentIcon,
|
|
||||||
Class as ClassIcon,
|
|
||||||
ExpandMore as ExpandMoreIcon
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigationStore } from '../../stores/navigationStore';
|
import { useNavigationStore } from '../../stores/navigationStore';
|
||||||
import { useNeoUser } from '../../contexts/NeoUserContext';
|
|
||||||
import { NAVIGATION_CONTEXTS } from '../../config/navigationContexts';
|
|
||||||
import {
|
|
||||||
BaseContext,
|
|
||||||
ViewContext
|
|
||||||
} from '../../types/navigation';
|
|
||||||
import { logger } from '../../debugConfig';
|
|
||||||
|
|
||||||
const NavigationRoot = styled(Box)`
|
const NavigationRoot = styled(Box)`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NavigationControls = styled(Box)`
|
function getNodeIcon(nodeType: string) {
|
||||||
display: flex;
|
switch (nodeType) {
|
||||||
align-items: center;
|
case 'User': return <HomeIcon fontSize="small" />;
|
||||||
gap: 4px;
|
case 'CalendarYear': return <CalendarToday fontSize="small" />;
|
||||||
`;
|
case 'CalendarMonth': return <DateRange fontSize="small" />;
|
||||||
|
case 'CalendarDay': return <Event fontSize="small" />;
|
||||||
const ContextToggleContainer = styled(Box)(({ theme }) => ({
|
default: return <WorkspacesOutlined fontSize="small" />;
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
padding: theme.spacing(0.5),
|
|
||||||
gap: theme.spacing(0.5),
|
|
||||||
'& .button-label': {
|
|
||||||
'@media (max-width: 500px)': {
|
|
||||||
display: 'none'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}));
|
}
|
||||||
|
|
||||||
const ContextToggleButton = styled(Button, {
|
|
||||||
shouldForwardProp: (prop) => prop !== 'active'
|
|
||||||
})<{ active?: boolean }>(({ theme, active }) => ({
|
|
||||||
minWidth: 0,
|
|
||||||
padding: theme.spacing(0.5, 1.5),
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
backgroundColor: active ? theme.palette.primary.main : 'transparent',
|
|
||||||
color: active ? theme.palette.primary.contrastText : theme.palette.text.primary,
|
|
||||||
textTransform: 'none',
|
|
||||||
transition: theme.transitions.create(['background-color', 'color'], {
|
|
||||||
duration: theme.transitions.duration.shorter,
|
|
||||||
}),
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover,
|
|
||||||
},
|
|
||||||
'@media (max-width: 500px)': {
|
|
||||||
padding: theme.spacing(0.5),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const GraphNavigator: React.FC = () => {
|
export const GraphNavigator: React.FC = () => {
|
||||||
const {
|
const { context, goBack, goForward, isLoading } = useNavigationStore();
|
||||||
context,
|
|
||||||
switchContext,
|
|
||||||
goBack,
|
|
||||||
goForward,
|
|
||||||
isLoading
|
|
||||||
} = useNavigationStore();
|
|
||||||
|
|
||||||
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
|
|
||||||
|
|
||||||
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
|
|
||||||
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
|
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [availableWidth, setAvailableWidth] = useState<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const calculateAvailableSpace = () => {
|
|
||||||
if (!rootRef.current) return;
|
|
||||||
|
|
||||||
// Get the header element
|
|
||||||
const header = rootRef.current.closest('.MuiToolbar-root');
|
|
||||||
if (!header) return;
|
|
||||||
|
|
||||||
// Get the title and menu elements
|
|
||||||
const title = header.querySelector('.app-title');
|
|
||||||
const menu = header.querySelector('.menu-button');
|
|
||||||
|
|
||||||
if (!title || !menu) return;
|
|
||||||
|
|
||||||
// Calculate available width
|
|
||||||
const headerWidth = header.clientWidth;
|
|
||||||
const titleWidth = title.clientWidth;
|
|
||||||
const menuWidth = menu.clientWidth;
|
|
||||||
const padding = 48; // Increased buffer space
|
|
||||||
|
|
||||||
const newAvailableWidth = headerWidth - titleWidth - menuWidth - padding;
|
|
||||||
console.log('Available width:', newAvailableWidth); // Debug log
|
|
||||||
setAvailableWidth(newAvailableWidth);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up ResizeObserver
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
// Use requestAnimationFrame to debounce calculations
|
|
||||||
window.requestAnimationFrame(calculateAvailableSpace);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Observe both the root element and the header
|
|
||||||
if (rootRef.current) {
|
|
||||||
const header = rootRef.current.closest('.MuiToolbar-root');
|
|
||||||
if (header) {
|
|
||||||
resizeObserver.observe(header);
|
|
||||||
resizeObserver.observe(rootRef.current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial calculation
|
|
||||||
calculateAvailableSpace();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Helper function to determine what should be visible
|
|
||||||
const getVisibility = () => {
|
|
||||||
// Adjusted thresholds and collapse order:
|
|
||||||
// 1. Navigation controls (back/forward/history) collapse first
|
|
||||||
// 2. Toggle labels collapse second
|
|
||||||
// 3. Context label collapses last
|
|
||||||
if (availableWidth < 300) {
|
|
||||||
return {
|
|
||||||
navigation: false,
|
|
||||||
contextLabel: true, // Keep context label visible longer
|
|
||||||
toggleLabels: false
|
|
||||||
};
|
|
||||||
} else if (availableWidth < 450) {
|
|
||||||
return {
|
|
||||||
navigation: false,
|
|
||||||
contextLabel: true, // Keep context label visible
|
|
||||||
toggleLabels: true
|
|
||||||
};
|
|
||||||
} else if (availableWidth < 600) {
|
|
||||||
return {
|
|
||||||
navigation: true,
|
|
||||||
contextLabel: true,
|
|
||||||
toggleLabels: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
navigation: true,
|
|
||||||
contextLabel: true,
|
|
||||||
toggleLabels: true
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibility = getVisibility();
|
|
||||||
|
|
||||||
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
|
|
||||||
setHistoryMenuAnchor(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHistoryClose = () => {
|
|
||||||
setHistoryMenuAnchor(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHistoryItemClick = (index: number) => {
|
|
||||||
const {currentIndex} = context.history;
|
|
||||||
const steps = index - currentIndex;
|
|
||||||
|
|
||||||
if (steps < 0) {
|
|
||||||
for (let i = 0; i < -steps; i++) {
|
|
||||||
goBack();
|
|
||||||
}
|
|
||||||
} else if (steps > 0) {
|
|
||||||
for (let i = 0; i < steps; i++) {
|
|
||||||
goForward();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHistoryClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContextChange = useCallback(async (newContext: BaseContext) => {
|
|
||||||
try {
|
|
||||||
// Check if trying to access institute contexts without worker database
|
|
||||||
if (['school', 'department', 'class'].includes(newContext) && !workerDbName) {
|
|
||||||
logger.error('navigation', '❌ Cannot switch to institute context: missing worker database');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Check if trying to access profile contexts without user database
|
|
||||||
if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) {
|
|
||||||
logger.error('navigation', '❌ Cannot switch to profile context: missing user database');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('navigation', '🔄 Changing main context', {
|
|
||||||
from: context.main,
|
|
||||||
to: newContext,
|
|
||||||
userDbName,
|
|
||||||
workerDbName
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get default view for new context
|
|
||||||
const defaultView = getDefaultViewForContext(newContext);
|
|
||||||
|
|
||||||
// Use unified context switch with both base and extended contexts
|
|
||||||
await switchContext({
|
|
||||||
main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute',
|
|
||||||
base: newContext,
|
|
||||||
extended: defaultView,
|
|
||||||
skipBaseContextLoad: false
|
|
||||||
}, userDbName, workerDbName);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to change context:', error);
|
|
||||||
}
|
|
||||||
}, [context.main, switchContext, userDbName, workerDbName]);
|
|
||||||
|
|
||||||
// Helper function to get default view for a context
|
|
||||||
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
|
|
||||||
switch (context) {
|
|
||||||
case 'calendar':
|
|
||||||
return 'overview';
|
|
||||||
case 'teaching':
|
|
||||||
return 'overview';
|
|
||||||
case 'school':
|
|
||||||
return 'overview';
|
|
||||||
case 'department':
|
|
||||||
return 'overview';
|
|
||||||
case 'class':
|
|
||||||
return 'overview';
|
|
||||||
default:
|
|
||||||
return 'overview';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
|
|
||||||
setContextMenuAnchor(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContextSelect = useCallback(async (context: BaseContext) => {
|
|
||||||
setContextMenuAnchor(null);
|
|
||||||
try {
|
|
||||||
// Use unified context switch with both base and extended contexts
|
|
||||||
const contextDef = NAVIGATION_CONTEXTS[context];
|
|
||||||
const defaultExtended = contextDef?.views[0]?.id;
|
|
||||||
|
|
||||||
await switchContext({
|
|
||||||
base: context,
|
|
||||||
extended: defaultExtended
|
|
||||||
}, userDbName, workerDbName);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to select context:', error);
|
|
||||||
}
|
|
||||||
}, [switchContext, userDbName, workerDbName]);
|
|
||||||
|
|
||||||
const getContextItems = useCallback(() => {
|
|
||||||
if (context.main === 'profile') {
|
|
||||||
return [
|
|
||||||
{ id: 'profile', label: 'Profile', icon: AccountCircleIcon },
|
|
||||||
{ id: 'calendar', label: 'Calendar', icon: CalendarIcon },
|
|
||||||
{ id: 'teaching', label: 'Teaching', icon: TeachingIcon },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
return [
|
|
||||||
{ id: 'school', label: 'School', icon: BusinessIcon },
|
|
||||||
{ id: 'department', label: 'Department', icon: DepartmentIcon },
|
|
||||||
{ id: 'class', label: 'Class', icon: ClassIcon },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}, [context.main]);
|
|
||||||
|
|
||||||
const getContextIcon = useCallback((contextType: string) => {
|
|
||||||
switch (contextType) {
|
|
||||||
case 'profile':
|
|
||||||
return <AccountCircleIcon />;
|
|
||||||
case 'calendar':
|
|
||||||
return <CalendarIcon />;
|
|
||||||
case 'teaching':
|
|
||||||
return <TeachingIcon />;
|
|
||||||
case 'school':
|
|
||||||
return <BusinessIcon />;
|
|
||||||
case 'department':
|
|
||||||
return <DepartmentIcon />;
|
|
||||||
case 'class':
|
|
||||||
return <ClassIcon />;
|
|
||||||
default:
|
|
||||||
return <AccountCircleIcon />;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isDisabled = !isNeoUserInitialized || isLoading;
|
|
||||||
const { history } = context;
|
const { history } = context;
|
||||||
const canGoBack = history.currentIndex > 0;
|
const canGoBack = history.currentIndex > 0;
|
||||||
const canGoForward = history.currentIndex < history.nodes.length - 1;
|
const canGoForward = history.currentIndex < history.nodes.length - 1;
|
||||||
|
const currentNode = context.node;
|
||||||
|
|
||||||
|
const handleHistoryClick = (e: React.MouseEvent<HTMLElement>) => setHistoryMenuAnchor(e.currentTarget);
|
||||||
|
const handleHistoryClose = () => setHistoryMenuAnchor(null);
|
||||||
|
const handleHistoryItemClick = (index: number) => {
|
||||||
|
const delta = index - history.currentIndex;
|
||||||
|
if (delta < 0) for (let i = 0; i < -delta; i++) goBack();
|
||||||
|
else if (delta > 0) for (let i = 0; i < delta; i++) goForward();
|
||||||
|
handleHistoryClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationRoot ref={rootRef}>
|
<NavigationRoot>
|
||||||
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
|
<Tooltip title="Back">
|
||||||
<Tooltip title="Back">
|
<span>
|
||||||
<span>
|
<IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small">
|
||||||
<IconButton
|
<ArrowBackIcon fontSize="small" />
|
||||||
onClick={goBack}
|
</IconButton>
|
||||||
disabled={!canGoBack || isDisabled}
|
</span>
|
||||||
size="small"
|
</Tooltip>
|
||||||
>
|
|
||||||
<ArrowBackIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="History">
|
<Tooltip title="History">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleHistoryClick}
|
onClick={handleHistoryClick}
|
||||||
disabled={!history.nodes.length || isDisabled}
|
disabled={!history.nodes.length}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
<HistoryIcon fontSize="small" />
|
<HistoryIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip title="Forward">
|
<Tooltip title="Forward">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton onClick={goForward} disabled={!canGoForward || isLoading} size="small">
|
||||||
onClick={goForward}
|
<ArrowForwardIcon fontSize="small" />
|
||||||
disabled={!canGoForward || isDisabled}
|
</IconButton>
|
||||||
size="small"
|
</span>
|
||||||
>
|
</Tooltip>
|
||||||
<ArrowForwardIcon fontSize="small" />
|
|
||||||
</IconButton>
|
{currentNode && (
|
||||||
</span>
|
<Chip
|
||||||
</Tooltip>
|
size="small"
|
||||||
</NavigationControls>
|
icon={getNodeIcon(currentNode.type)}
|
||||||
|
label={currentNode.label || currentNode.type}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ maxWidth: 200, fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* History Menu */}
|
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={historyMenuAnchor}
|
anchorEl={historyMenuAnchor}
|
||||||
open={Boolean(historyMenuAnchor)}
|
open={Boolean(historyMenuAnchor)}
|
||||||
onClose={handleHistoryClose}
|
onClose={handleHistoryClose}
|
||||||
anchorOrigin={{
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
vertical: 'bottom',
|
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
horizontal: 'center',
|
|
||||||
}}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: 'top',
|
|
||||||
horizontal: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{history.nodes.map((node, index) => (
|
{history.nodes.map((node, index) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={`${node.id}-${index}`}
|
key={`${node.id}-${index}`}
|
||||||
onClick={() => handleHistoryItemClick(index)}
|
onClick={() => handleHistoryItemClick(index)}
|
||||||
selected={index === history.currentIndex}
|
selected={index === history.currentIndex}
|
||||||
|
dense
|
||||||
>
|
>
|
||||||
<ListItemIcon>
|
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||||
{getContextIcon(node.type)}
|
{getNodeIcon(node.type)}
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={node.label || node.id}
|
primary={node.label || node.id}
|
||||||
secondary={node.type}
|
secondary={node.type}
|
||||||
|
primaryTypographyProps={{ fontSize: '0.8rem' }}
|
||||||
|
secondaryTypographyProps={{ fontSize: '0.7rem' }}
|
||||||
/>
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<ContextToggleContainer>
|
|
||||||
<ContextToggleButton
|
|
||||||
active={context.main === 'profile'}
|
|
||||||
onClick={() => handleContextChange('profile' as BaseContext)}
|
|
||||||
startIcon={<PersonIcon />}
|
|
||||||
disabled={isDisabled || !userDbName}
|
|
||||||
>
|
|
||||||
{visibility.toggleLabels && <span className="button-label">Profile</span>}
|
|
||||||
</ContextToggleButton>
|
|
||||||
<ContextToggleButton
|
|
||||||
active={context.main === 'institute'}
|
|
||||||
onClick={() => handleContextChange('school' as BaseContext)}
|
|
||||||
startIcon={<SchoolIcon />}
|
|
||||||
disabled={isDisabled || !workerDbName}
|
|
||||||
>
|
|
||||||
{visibility.toggleLabels && <span className="button-label">Institute</span>}
|
|
||||||
</ContextToggleButton>
|
|
||||||
</ContextToggleContainer>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Tooltip title={context.base}>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
onClick={handleContextMenu}
|
|
||||||
disabled={isDisabled}
|
|
||||||
sx={{
|
|
||||||
minWidth: 0,
|
|
||||||
p: 0.5,
|
|
||||||
color: 'text.primary',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'action.hover'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getContextIcon(context.base)}
|
|
||||||
{visibility.contextLabel && (
|
|
||||||
<Box sx={{ ml: 1 }}>
|
|
||||||
{context.base}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<ExpandMoreIcon sx={{ ml: visibility.contextLabel ? 0.5 : 0 }} />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Menu
|
|
||||||
anchorEl={contextMenuAnchor}
|
|
||||||
open={Boolean(contextMenuAnchor)}
|
|
||||||
onClose={() => setContextMenuAnchor(null)}
|
|
||||||
>
|
|
||||||
{getContextItems().map(item => (
|
|
||||||
<MenuItem
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => handleContextSelect(item.id as BaseContext)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<item.icon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary={item.label} />
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</NavigationRoot>
|
</NavigationRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box, IconButton, CircularProgress, Tooltip, Collapse,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ChevronLeft, ChevronRight,
|
||||||
|
ExpandMore, ChevronRight as ChevronRightIcon,
|
||||||
|
Home as HomeIcon, CalendarToday, DateRange, Event,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigationStore } from '../../stores/navigationStore';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { NeoGraphNode } from '../../types/navigation';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
interface TreeNode extends NeoGraphNode {
|
||||||
|
has_children?: boolean;
|
||||||
|
children?: TreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_ICONS: Record<string, React.ElementType> = {
|
||||||
|
User: HomeIcon,
|
||||||
|
CalendarYear: CalendarToday,
|
||||||
|
CalendarMonth: DateRange,
|
||||||
|
CalendarWeek: DateRange,
|
||||||
|
CalendarDay: Event,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH = 220;
|
||||||
|
|
||||||
|
interface TreeItemProps {
|
||||||
|
node: TreeNode;
|
||||||
|
depth: number;
|
||||||
|
onSelect: (node: TreeNode) => void;
|
||||||
|
onExpand: (node: TreeNode) => Promise<TreeNode[]>;
|
||||||
|
activeRoomId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeItem({ node, depth, onSelect, onExpand, activeRoomId }: TreeItemProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [children, setChildren] = useState<TreeNode[]>(node.children || []);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const Icon = NODE_ICONS[node.node_type] || HomeIcon;
|
||||||
|
const canExpand = node.has_children !== false && node.node_type !== 'CalendarDay';
|
||||||
|
|
||||||
|
const handleToggle = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!expanded && children.length === 0 && canExpand) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const loaded = await onExpand(node);
|
||||||
|
setChildren(loaded);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpanded(v => !v);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
onClick={() => onSelect(node)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.4,
|
||||||
|
cursor: 'pointer', borderRadius: 1, mx: 0.5,
|
||||||
|
fontSize: '0.78rem', minHeight: 28,
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||||
|
{canExpand && (
|
||||||
|
loading
|
||||||
|
? <CircularProgress size={10} />
|
||||||
|
: (
|
||||||
|
<IconButton
|
||||||
|
size="small" sx={{ p: 0, color: 'text.secondary' }}
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? <ExpandMore sx={{ fontSize: 14 }} />
|
||||||
|
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Icon sx={{ fontSize: 14, mr: 0.75, flexShrink: 0, color: 'text.secondary' }} />
|
||||||
|
<Box sx={{
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
flexGrow: 1, color: 'text.primary',
|
||||||
|
}}>
|
||||||
|
{node.label}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{canExpand && (
|
||||||
|
<Collapse in={expanded} timeout="auto">
|
||||||
|
{children.map(child => (
|
||||||
|
<TreeItem
|
||||||
|
key={child.neo4j_node_id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onExpand={onExpand}
|
||||||
|
activeRoomId={activeRoomId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphSidebar({ open, onToggle }: GraphSidebarProps) {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const { navigateToNeoNode, context } = useNavigationStore();
|
||||||
|
const [tree, setTree] = useState<TreeNode | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const apiBase = import.meta.env.VITE_API_BASE as string;
|
||||||
|
|
||||||
|
const fetchTree = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/graph/tree`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Graph tree fetch failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setTree(data.tree);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('graph-sidebar', 'Failed to load graph tree', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !tree && accessToken) fetchTree();
|
||||||
|
}, [open, tree, accessToken, fetchTree]);
|
||||||
|
|
||||||
|
const handleExpand = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
|
||||||
|
if (!accessToken) return [];
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
neo4j_node_id: node.neo4j_node_id,
|
||||||
|
neo4j_db_name: node.neo4j_db_name,
|
||||||
|
node_type: node.node_type,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.children || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'relative', height: '100%', display: 'flex', flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: open ? SIDEBAR_WIDTH : 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRight: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ overflowY: 'auto', flexGrow: 1, pt: 1 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 3 }}>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
</Box>
|
||||||
|
) : tree ? (
|
||||||
|
<TreeItem
|
||||||
|
node={tree}
|
||||||
|
depth={0}
|
||||||
|
onSelect={n => navigateToNeoNode(n)}
|
||||||
|
onExpand={handleExpand}
|
||||||
|
activeRoomId={context.node?.id}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title={open ? 'Collapse sidebar' : 'Expand sidebar'} placement="right">
|
||||||
|
<IconButton
|
||||||
|
onClick={onToggle}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -14,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
width: 22,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{open
|
||||||
|
? <ChevronLeft sx={{ fontSize: 14 }} />
|
||||||
|
: <ChevronRight sx={{ fontSize: 14 }} />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Centralized API configuration for Classroom Copilot.
|
||||||
|
*
|
||||||
|
* Resolves the API base URL from environment variables with proper fallbacks.
|
||||||
|
* - In dev: VITE_API_BASE should always be set (e.g., http://192.168.0.64:18000)
|
||||||
|
* - In production: VITE_API_BASE should be set to the production API URL
|
||||||
|
* - Fallback: "/api" (relative path, works when API is served from same origin)
|
||||||
|
*
|
||||||
|
* IMPORTANT: Never hardcode localhost or 127.0.0.1 as fallbacks - they break
|
||||||
|
* remote dev access and do not work in containerized environments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary API base URL. Use this for all API calls.
|
||||||
|
* Resolved from VITE_API_BASE (preferred) or VITE_API_URL (legacy alias).
|
||||||
|
*/
|
||||||
|
export const API_BASE: string =
|
||||||
|
import.meta.env.VITE_API_BASE ||
|
||||||
|
import.meta.env.VITE_API_URL ||
|
||||||
|
"/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLSync worker URL for tldraw multiplayer.
|
||||||
|
* Resolved from VITE_TLSYNC_URL (preferred) or derived from VITE_FRONTEND_SITE_URL.
|
||||||
|
*/
|
||||||
|
export const TLSYNC_URL: string =
|
||||||
|
import.meta.env.VITE_TLSYNC_URL ||
|
||||||
|
(import.meta.env.VITE_FRONTEND_SITE_URL?.startsWith("http")
|
||||||
|
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
|
||||||
|
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhisperLive WebSocket URL for live transcription.
|
||||||
|
* Resolved from VITE_WHISPERLIVE_URL. Must be set in env.
|
||||||
|
*/
|
||||||
|
export const WHISPERLIVE_URL: string | undefined =
|
||||||
|
import.meta.env.VITE_WHISPERLIVE_URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search proxy URL (searxng).
|
||||||
|
* Resolved from VITE_SEARCH_URL.
|
||||||
|
*/
|
||||||
|
export const SEARCH_URL: string =
|
||||||
|
import.meta.env.VITE_SEARCH_URL || "/searxng-api/";
|
||||||
+89
-152
@@ -4,35 +4,46 @@ import { Session, User } from '@supabase/supabase-js';
|
|||||||
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
|
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { supabase } from '../supabaseClient';
|
import { supabase } from '../supabaseClient';
|
||||||
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
|
||||||
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||||
|
import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService';
|
||||||
|
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
user: CCUser | null;
|
user: CCUser | null;
|
||||||
user_role: string | null;
|
user_role: string | null;
|
||||||
|
accessToken: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
signIn: (email: string, password: string) => Promise<void>;
|
signIn: (email: string, password: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
bootstrapData: BootstrapResponse | null;
|
||||||
|
isAuthResolving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType>({
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
user: null,
|
user: null,
|
||||||
user_role: null,
|
user_role: null,
|
||||||
|
accessToken: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
signIn: async () => {},
|
signIn: async () => {},
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
clearError: () => {}
|
clearError: () => {},
|
||||||
|
bootstrapData: null,
|
||||||
|
isAuthResolving: false
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [user, setUser] = useState<CCUser | null>(null);
|
const [user, setUser] = useState<CCUser | null>(null);
|
||||||
const [user_role, setUserRole] = useState<string | null>(null);
|
const [user_role, setUserRole] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
||||||
|
const [isAuthResolving, setIsAuthResolving] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const persistSession = useCallback((session: Session | null) => {
|
const persistSession = useCallback((session: Session | null) => {
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -42,40 +53,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const restoreSessionFromStorage = useCallback(async (): Promise<Session | null> => {
|
|
||||||
const persistedSession = storageService.get(StorageKeys.SUPABASE_SESSION);
|
|
||||||
|
|
||||||
if (!persistedSession) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!persistedSession.access_token || !persistedSession.refresh_token) {
|
|
||||||
storageService.remove(StorageKeys.SUPABASE_SESSION);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: restored, error: restoreError } = await supabase.auth.setSession({
|
|
||||||
access_token: persistedSession.access_token,
|
|
||||||
refresh_token: persistedSession.refresh_token,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (restoreError) {
|
|
||||||
logger.warn('auth-context', '⚠️ Failed to restore persisted Supabase session', {
|
|
||||||
error: restoreError.message ?? restoreError,
|
|
||||||
});
|
|
||||||
storageService.remove(StorageKeys.SUPABASE_SESSION);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (restored.session) {
|
|
||||||
persistSession(restored.session);
|
|
||||||
return restored.session;
|
|
||||||
}
|
|
||||||
|
|
||||||
storageService.remove(StorageKeys.SUPABASE_SESSION);
|
|
||||||
return null;
|
|
||||||
}, [persistSession]);
|
|
||||||
|
|
||||||
const buildUserFromSupabase = useCallback(async (supabaseUser: User | null): Promise<{ user: CCUser | null; role: string | null }> => {
|
const buildUserFromSupabase = useCallback(async (supabaseUser: User | null): Promise<{ user: CCUser | null; role: string | null }> => {
|
||||||
if (!supabaseUser) {
|
if (!supabaseUser) {
|
||||||
return { user: null, role: null };
|
return { user: null, role: null };
|
||||||
@@ -86,20 +63,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
|
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
|
||||||
const userType = (metadata.user_type || 'email_teacher').trim();
|
const userType = (metadata.user_type || 'email_teacher').trim();
|
||||||
|
|
||||||
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
|
|
||||||
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
|
|
||||||
|
|
||||||
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(userType || 'standard', supabaseUser.id);
|
|
||||||
const schoolDbName = storedSchoolDb || '';
|
|
||||||
|
|
||||||
const resolvedUser: CCUser = {
|
const resolvedUser: CCUser = {
|
||||||
id: supabaseUser.id,
|
id: supabaseUser.id,
|
||||||
email: supabaseUser.email,
|
email: supabaseUser.email,
|
||||||
user_type: userType,
|
user_type: userType,
|
||||||
username: baseUsername,
|
username: baseUsername,
|
||||||
display_name: baseDisplayName,
|
display_name: baseDisplayName,
|
||||||
user_db_name: userDbName,
|
school_id: null,
|
||||||
school_db_name: schoolDbName,
|
|
||||||
created_at: supabaseUser.created_at,
|
created_at: supabaseUser.created_at,
|
||||||
updated_at: supabaseUser.updated_at
|
updated_at: supabaseUser.updated_at
|
||||||
};
|
};
|
||||||
@@ -108,128 +78,90 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return { user: resolvedUser, role: resolvedRole };
|
return { user: resolvedUser, role: resolvedRole };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadBootstrap = useCallback(async (token: string | null | undefined) => {
|
||||||
|
if (!token) {
|
||||||
|
setBootstrapData(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bootstrap = await fetchBootstrapData(token);
|
||||||
|
setBootstrapData(bootstrap);
|
||||||
|
return bootstrap;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadInitialSession = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const { data: { session }, error } = await supabase.auth.getSession();
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeSession: Session | null = session ?? null;
|
|
||||||
|
|
||||||
if (!activeSession) {
|
|
||||||
activeSession = await restoreSessionFromStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSession?.user) {
|
|
||||||
persistSession(activeSession);
|
|
||||||
try {
|
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(activeSession.user);
|
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUser(resolvedUser);
|
|
||||||
setUserRole(role);
|
|
||||||
} catch (buildError) {
|
|
||||||
logger.error('auth-context', '❌ Failed to build user from initial session', {
|
|
||||||
error: buildError,
|
|
||||||
});
|
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUser(null);
|
|
||||||
setUserRole(null);
|
|
||||||
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
persistSession(null);
|
|
||||||
setUser(null);
|
|
||||||
setUserRole(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('auth-context', '❌ Failed to load initial session', { error });
|
|
||||||
if (isMounted) {
|
|
||||||
setError(error instanceof Error ? error : new Error('Failed to load user'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInitialSession();
|
|
||||||
|
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
async (event, session) => {
|
||||||
if (!isMounted) {
|
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
|
||||||
|
|
||||||
|
if (event === 'SIGNED_IN') {
|
||||||
|
persistSession(session ?? null);
|
||||||
|
setIsAuthResolving(!!session?.user);
|
||||||
|
if (session?.user) {
|
||||||
|
try {
|
||||||
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
|
setUser(resolvedUser);
|
||||||
|
setUserRole(role);
|
||||||
|
setAccessToken(session.access_token ?? null);
|
||||||
|
await loadBootstrap(session.access_token);
|
||||||
|
} catch (buildError) {
|
||||||
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||||
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
setIsAuthResolving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (event) {
|
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
||||||
case 'SIGNED_IN':
|
persistSession(session ?? null);
|
||||||
case 'TOKEN_REFRESHED':
|
setIsAuthResolving(!!session?.user);
|
||||||
case 'INITIAL_SESSION': {
|
if (session?.user) {
|
||||||
persistSession(session ?? null);
|
try {
|
||||||
if (session?.user) {
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
setLoading(true);
|
setUser(resolvedUser);
|
||||||
try {
|
setUserRole(role);
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
setAccessToken(session.access_token ?? null);
|
||||||
if (!isMounted) {
|
await loadBootstrap(session.access_token);
|
||||||
return;
|
} catch (buildError) {
|
||||||
}
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||||
setUser(resolvedUser);
|
|
||||||
setUserRole(role);
|
|
||||||
} catch (buildError) {
|
|
||||||
logger.error('auth-context', '❌ Failed to build user from session', {
|
|
||||||
event,
|
|
||||||
error: buildError,
|
|
||||||
});
|
|
||||||
setUser(null);
|
|
||||||
setUserRole(null);
|
|
||||||
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
if (isMounted) {
|
setAccessToken(null);
|
||||||
setLoading(false);
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
} else {
|
||||||
}
|
|
||||||
case 'SIGNED_OUT': {
|
|
||||||
persistSession(null);
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
if (isMounted) {
|
setAccessToken(null);
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
default:
|
setLoading(false);
|
||||||
break;
|
setIsAuthResolving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'SIGNED_OUT') {
|
||||||
|
persistSession(null);
|
||||||
|
setIsAuthResolving(false);
|
||||||
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
setBootstrapData(null);
|
||||||
|
invalidateBootstrapCache();
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => subscription.unsubscribe();
|
||||||
isMounted = false;
|
}, [buildUserFromSupabase, persistSession, loadBootstrap]);
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, [buildUserFromSupabase, persistSession, restoreSessionFromStorage]);
|
|
||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -249,6 +181,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
|
||||||
setUser(resolvedUser);
|
setUser(resolvedUser);
|
||||||
setUserRole(role);
|
setUserRole(role);
|
||||||
|
setAccessToken(data.session?.access_token ?? null);
|
||||||
|
await loadBootstrap(data.session?.access_token ?? null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('auth-context', '❌ Sign in failed', { error });
|
logger.error('auth-context', '❌ Sign in failed', { error });
|
||||||
@@ -282,11 +216,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
user_role,
|
user_role,
|
||||||
|
accessToken,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
clearError
|
clearError,
|
||||||
|
bootstrapData,
|
||||||
|
isAuthResolving
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { SchoolNeoDBService } from '../services/graph/schoolNeoDBService';
|
|||||||
import { CCSchoolNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
import { CCSchoolNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
|
|
||||||
|
const NEO_INSTITUTE_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
export interface NeoInstituteContextType {
|
export interface NeoInstituteContextType {
|
||||||
schoolNode: CCSchoolNodeProps | null;
|
schoolNode: CCSchoolNodeProps | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -58,7 +60,17 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
|
|||||||
userEmail: user?.email
|
userEmail: user?.email
|
||||||
});
|
});
|
||||||
|
|
||||||
const node = await SchoolNeoDBService.getSchoolNode(profile.school_db_name);
|
// Race the Neo4j call against a timeout so a slow/unavailable Neo4j
|
||||||
|
// never blocks the workspace from loading for more than 8 seconds.
|
||||||
|
const timeoutPromise = new Promise<null>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Neo4j timed out after ${NEO_INSTITUTE_TIMEOUT_MS}ms`)), NEO_INSTITUTE_TIMEOUT_MS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = await Promise.race([
|
||||||
|
SchoolNeoDBService.getSchoolNode(profile.school_db_name),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
|
||||||
if (node) {
|
if (node) {
|
||||||
setSchoolNode(node);
|
setSchoolNode(node);
|
||||||
logger.debug('neo-institute-context', '✅ School node loaded', {
|
logger.debug('neo-institute-context', '✅ School node loaded', {
|
||||||
@@ -71,7 +83,7 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load school node';
|
const errorMessage = error instanceof Error ? error.message : 'Failed to load school node';
|
||||||
logger.error('neo-institute-context', '❌ Failed to load school node', {
|
logger.warn('neo-institute-context', '⚠️ School node unavailable — workspace will load without institute data', {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
schoolDbName: profile.school_db_name
|
schoolDbName: profile.school_db_name
|
||||||
});
|
});
|
||||||
@@ -79,7 +91,7 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
logger.debug('neo-institute-context', '✅ Institute context initialization complete');
|
logger.debug('neo-institute-context', '✅ Institute context initialization complete');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useAuth } from './AuthContext';
|
|||||||
import { useUser } from './UserContext';
|
import { useUser } from './UserContext';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||||
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
|
||||||
import { CalendarStructure, WorkerStructure } from '../types/navigation';
|
import { CalendarStructure, WorkerStructure } from '../types/navigation';
|
||||||
import { useNavigationStore } from '../stores/navigationStore';
|
import { useNavigationStore } from '../stores/navigationStore';
|
||||||
|
|
||||||
@@ -131,7 +130,7 @@ const NeoUserContext = createContext<NeoUserContextType>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const { user } = useAuth();
|
const { user, accessToken } = useAuth();
|
||||||
const { profile, isInitialized: isUserInitialized } = useUser();
|
const { profile, isInitialized: isUserInitialized } = useUser();
|
||||||
const navigationStore = useNavigationStore();
|
const navigationStore = useNavigationStore();
|
||||||
|
|
||||||
@@ -215,24 +214,27 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Set database names
|
// Inject auth into navigation store so Supabase queries work
|
||||||
const userDb = profile.user_db_name || (user?.email ?
|
if (user?.id && accessToken) {
|
||||||
DatabaseNameService.getStoredUserDatabase() || null : null);
|
navigationStore.setAuthInfo(accessToken, user.id);
|
||||||
|
|
||||||
if (!userDb) {
|
|
||||||
throw new Error('No user database name available');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize user node in profile context
|
// Initialize user node in profile context
|
||||||
logger.debug('neo-user-context', '🔄 Starting context initialization');
|
logger.debug('neo-user-context', '🔄 Starting context initialization');
|
||||||
|
|
||||||
// Initialize user node
|
// Initialize user node — race against 8s timeout so spinner never hangs
|
||||||
|
const switchTimeout = new Promise<void>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('switchContext timed out after 8000ms')), 8000)
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await Promise.race([
|
||||||
main: 'profile',
|
navigationStore.switchContext({
|
||||||
base: 'profile',
|
main: 'profile',
|
||||||
extended: 'overview'
|
base: 'profile',
|
||||||
}, userDb, profile.school_db_name);
|
extended: 'overview'
|
||||||
|
}, null, null),
|
||||||
|
switchTimeout
|
||||||
|
]);
|
||||||
|
|
||||||
const userNavigationNode = navigationStore.context.node;
|
const userNavigationNode = navigationStore.context.node;
|
||||||
if (userNavigationNode?.id && userNavigationNode?.data) {
|
if (userNavigationNode?.id && userNavigationNode?.data) {
|
||||||
@@ -265,9 +267,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
// Continue without user node - this is not critical for basic functionality
|
// Continue without user node - this is not critical for basic functionality
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set final state
|
// Set final state — userDbName signals auth availability for UI guards
|
||||||
setUserDbName(userDb);
|
setUserDbName(user?.id || null);
|
||||||
setWorkerDbName(profile.school_db_name);
|
setWorkerDbName(null);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
initializationRef.current.isComplete = true;
|
initializationRef.current.isComplete = true;
|
||||||
@@ -288,13 +290,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
// Calendar Navigation Functions
|
// Calendar Navigation Functions
|
||||||
const navigateToDay = async (id: string) => {
|
const navigateToDay = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'calendar',
|
base: 'calendar',
|
||||||
extended: 'day'
|
extended: 'day'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@@ -328,13 +330,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToWeek = async (id: string) => {
|
const navigateToWeek = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'calendar',
|
base: 'calendar',
|
||||||
extended: 'week'
|
extended: 'week'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@@ -368,13 +370,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToMonth = async (id: string) => {
|
const navigateToMonth = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'calendar',
|
base: 'calendar',
|
||||||
extended: 'month'
|
extended: 'month'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@@ -408,13 +410,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToYear = async (id: string) => {
|
const navigateToYear = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'calendar',
|
base: 'calendar',
|
||||||
extended: 'year'
|
extended: 'year'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@@ -449,13 +451,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
// Worker Navigation Functions
|
// Worker Navigation Functions
|
||||||
const navigateToTimetable = async (id: string) => {
|
const navigateToTimetable = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'timetable'
|
extended: 'timetable'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@@ -486,13 +488,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToJournal = async (id: string) => {
|
const navigateToJournal = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'journal'
|
extended: 'journal'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@@ -523,13 +525,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToPlanner = async (id: string) => {
|
const navigateToPlanner = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'planner'
|
extended: 'planner'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@@ -560,14 +562,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToClass = async (id: string) => {
|
const navigateToClass = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'classes'
|
extended: 'classes'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
await navigationStore.navigate(id, userDbName);
|
await navigationStore.navigate(id, '');
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
@@ -598,14 +600,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToLesson = async (id: string) => {
|
const navigateToLesson = async (id: string) => {
|
||||||
if (!userDbName) return;
|
if (!user?.id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await navigationStore.switchContext({
|
await navigationStore.switchContext({
|
||||||
base: 'teaching',
|
base: 'teaching',
|
||||||
extended: 'lessons'
|
extended: 'lessons'
|
||||||
}, userDbName, workerDbName);
|
}, null, null);
|
||||||
await navigationStore.navigate(id, userDbName);
|
await navigationStore.navigate(id, '');
|
||||||
|
|
||||||
const node = navigationStore.context.node;
|
const node = navigationStore.context.node;
|
||||||
if (node?.data) {
|
if (node?.data) {
|
||||||
|
|||||||
+32
-149
@@ -4,8 +4,6 @@ import { supabase } from '../supabaseClient';
|
|||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { CCUser, CCUserMetadata } from '../services/auth/authService';
|
import { CCUser, CCUserMetadata } from '../services/auth/authService';
|
||||||
import { UserPreferences } from '../services/auth/profileService';
|
import { UserPreferences } from '../services/auth/profileService';
|
||||||
import { DatabaseNameService } from '../services/graph/databaseNameService';
|
|
||||||
import { provisionUser } from '../services/provisioningService';
|
|
||||||
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||||
|
|
||||||
export interface UserContextType {
|
export interface UserContextType {
|
||||||
@@ -112,89 +110,49 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
|
|
||||||
let profileRow: Record<string, unknown> | null = null;
|
let profileRow: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
logger.debug('user-context', '🔧 Step 5: Querying profiles table...', {
|
// Fast-path: build profile from auth metadata immediately (no spinner on refresh).
|
||||||
userId: userInfo.id
|
const fastMetadata = userInfo.user_metadata as CCUserMetadata;
|
||||||
});
|
const fastProfile: CCUser = {
|
||||||
|
id: userInfo.id,
|
||||||
|
email: userInfo.email,
|
||||||
|
user_type: fastMetadata?.user_type || '',
|
||||||
|
username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user',
|
||||||
|
display_name: String(fastMetadata?.display_name || ''),
|
||||||
|
school_id: null,
|
||||||
|
created_at: userInfo.created_at,
|
||||||
|
updated_at: userInfo.updated_at
|
||||||
|
};
|
||||||
|
if (mountedRef.current && !isInitialized) {
|
||||||
|
setProfile(fastProfile);
|
||||||
|
setLoading(false);
|
||||||
|
setIsInitialized(true);
|
||||||
|
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata');
|
||||||
|
}
|
||||||
|
|
||||||
// Set loading state when we start the actual database query
|
// Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name).
|
||||||
setLoading(true);
|
// No setLoading toggles — spinner is already cleared above.
|
||||||
|
const { data, error } = await supabase
|
||||||
// Query profiles table without timeout to see actual error
|
.from('profiles')
|
||||||
logger.debug('user-context', '🔧 Step 5b: Starting profiles query...', {
|
.select('*')
|
||||||
userId: userInfo.id,
|
.eq('id', userInfo.id)
|
||||||
clientType: 'authenticated'
|
.single();
|
||||||
});
|
|
||||||
|
|
||||||
// Try direct fetch instead of Supabase client to bypass hanging issue
|
|
||||||
logger.debug('user-context', '🔧 Step 5b1: About to make profiles query with direct fetch...', {
|
|
||||||
userId: userInfo.id,
|
|
||||||
queryStarted: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, error } = await fetch(`http://localhost:8000/rest/v1/profiles?select=*&id=eq.${userInfo.id}`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
return { data: result[0] || null, error: null };
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
logger.debug('user-context', '🔧 Step 5b1: Direct fetch failed', {
|
|
||||||
userId: userInfo?.id,
|
|
||||||
error: err.message
|
|
||||||
});
|
|
||||||
return { data: null, error: { message: err.message, code: 'FETCH_ERROR' } };
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('user-context', '🔧 Step 5b2: Direct fetch completed...', {
|
|
||||||
userId: userInfo.id,
|
|
||||||
hasData: !!data,
|
|
||||||
hasError: !!error
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('user-context', '🔧 Step 5c: Profiles query completed', {
|
|
||||||
hasData: !!data,
|
|
||||||
hasError: !!error,
|
|
||||||
errorCode: error?.code,
|
|
||||||
errorMessage: error?.message
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('user-context', '🔧 Step 5a: Profiles query result', {
|
|
||||||
hasData: !!data,
|
|
||||||
hasError: !!error,
|
|
||||||
errorCode: error?.code,
|
|
||||||
errorMessage: error?.message
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error && error.code !== 'PGRST116') {
|
if (error && error.code !== 'PGRST116') {
|
||||||
logger.warn('user-context', '⚠️ Profiles query failed, using fallback', {
|
logger.warn('user-context', '⚠️ Profiles query failed, using fast-path profile', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
code: error.code
|
code: error.code
|
||||||
});
|
});
|
||||||
// Don't throw error, just use fallback profile
|
|
||||||
profileRow = null;
|
profileRow = null;
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
profileRow = data;
|
profileRow = data;
|
||||||
logger.debug('user-context', '✅ Found profile data in database', {
|
logger.debug('user-context', '✅ Supabase profile fetched', {
|
||||||
userId: data.id,
|
userId: data.id,
|
||||||
userType: data.user_type,
|
|
||||||
userDbName: data.user_db_name
|
userDbName: data.user_db_name
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.debug('user-context', '⚠️ No profile data found - will create default');
|
|
||||||
profileRow = null;
|
profileRow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear loading state after profiles query completes
|
|
||||||
setLoading(false);
|
|
||||||
logger.debug('user-context', '🔧 Step 5d: Loading state cleared');
|
|
||||||
|
|
||||||
logger.debug('user-context', '🔧 Step 6: Processing profile data...', {
|
logger.debug('user-context', '🔧 Step 6: Processing profile data...', {
|
||||||
userId: userInfo.id,
|
userId: userInfo.id,
|
||||||
hasProfileRow: !!profileRow,
|
hasProfileRow: !!profileRow,
|
||||||
@@ -202,80 +160,15 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
hasSchoolDb: !!profileRow?.school_db_name
|
hasSchoolDb: !!profileRow?.school_db_name
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const metadata = userInfo.user_metadata as CCUserMetadata;
|
const metadata = userInfo.user_metadata as CCUserMetadata;
|
||||||
logger.debug('user-context', '🔧 Step 7: Processing user metadata...', {
|
|
||||||
hasMetadata: !!metadata,
|
|
||||||
userType: metadata?.user_type
|
|
||||||
});
|
|
||||||
let userDbName = profileRow?.user_db_name ?? null;
|
|
||||||
let schoolDbName = profileRow?.school_db_name ?? null;
|
|
||||||
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
|
|
||||||
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
|
|
||||||
|
|
||||||
// Start provisioning in background (non-blocking)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const provisioningPromise = provisionUser(userInfo.id, authSession?.access_token ?? null)
|
|
||||||
.then(provisioned => {
|
|
||||||
if (provisioned) {
|
|
||||||
logger.debug('user-context', '✅ Provisioning completed in background', {
|
|
||||||
userDbName: provisioned.user_db_name,
|
|
||||||
workerDbName: provisioned.worker_db_name
|
|
||||||
});
|
|
||||||
// Update localStorage with provisioned values
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: provisioned.user_db_name,
|
|
||||||
schoolDbName: provisioned.worker_db_name || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(provisionError => {
|
|
||||||
logger.warn('user-context', '⚠️ Background provisioning failed', {
|
|
||||||
userId: userInfo?.id,
|
|
||||||
provisionError: provisionError instanceof Error ? provisionError.message : String(provisionError)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userDbName && storedUserDb) {
|
|
||||||
userDbName = storedUserDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!schoolDbName && storedSchoolDb) {
|
|
||||||
schoolDbName = storedSchoolDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('user-context', 'ℹ️ Database name resolution', {
|
|
||||||
userDbName,
|
|
||||||
schoolDbName
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userDbName) {
|
|
||||||
userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', userInfo.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!schoolDbName) {
|
|
||||||
schoolDbName = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: String(userDbName || ''),
|
|
||||||
schoolDbName: String(schoolDbName || '')
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('user-context', '🔧 Creating user profile object...', {
|
|
||||||
userId: userInfo.id,
|
|
||||||
userDbName,
|
|
||||||
schoolDbName,
|
|
||||||
userType: metadata.user_type
|
|
||||||
});
|
|
||||||
|
|
||||||
const userProfile: CCUser = {
|
const userProfile: CCUser = {
|
||||||
id: userInfo.id,
|
id: userInfo.id,
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
user_type: metadata.user_type || '',
|
user_type: metadata.user_type || '',
|
||||||
username: metadata.username || '',
|
username: metadata.username || '',
|
||||||
display_name: String(metadata.display_name || ''),
|
display_name: String(metadata.display_name || ''),
|
||||||
user_db_name: String(userDbName || ''),
|
school_id: (profileRow?.school_id as string) ?? null,
|
||||||
school_db_name: String(schoolDbName || ''),
|
|
||||||
created_at: userInfo.created_at,
|
created_at: userInfo.created_at,
|
||||||
updated_at: userInfo.updated_at
|
updated_at: userInfo.updated_at
|
||||||
};
|
};
|
||||||
@@ -298,9 +191,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
logger.debug('user-context', '✅ User profile loaded', {
|
logger.debug('user-context', '✅ User profile loaded', {
|
||||||
userId: userProfile.id,
|
userId: userProfile.id,
|
||||||
userType: userProfile.user_type,
|
userType: userProfile.user_type,
|
||||||
username: userProfile.username,
|
username: userProfile.username
|
||||||
userDbName: userProfile.user_db_name,
|
|
||||||
schoolDbName: userProfile.school_db_name
|
|
||||||
});
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -325,22 +216,14 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
user_type: metadata?.user_type || 'email_teacher',
|
user_type: metadata?.user_type || 'email_teacher',
|
||||||
username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
|
username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
|
||||||
display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User',
|
display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User',
|
||||||
user_db_name: DatabaseNameService.getUserPrivateDB(metadata?.user_type || 'email_teacher', userInfo.id),
|
school_id: null,
|
||||||
school_db_name: '',
|
|
||||||
created_at: userInfo.created_at,
|
created_at: userInfo.created_at,
|
||||||
updated_at: userInfo.updated_at
|
updated_at: userInfo.updated_at
|
||||||
};
|
};
|
||||||
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: fallbackProfile.user_db_name,
|
|
||||||
schoolDbName: fallbackProfile.school_db_name
|
|
||||||
});
|
|
||||||
|
|
||||||
setProfile(fallbackProfile);
|
setProfile(fallbackProfile);
|
||||||
logger.debug('user-context', '✅ Fallback profile created', {
|
logger.debug('user-context', '✅ Fallback profile created', {
|
||||||
userId: fallbackProfile.id,
|
userId: fallbackProfile.id,
|
||||||
userType: fallbackProfile.user_type,
|
userType: fallbackProfile.user_type
|
||||||
userDbName: fallbackProfile.user_db_name
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
|
|||||||
+5
-1
@@ -23,6 +23,7 @@ export type LogCategory =
|
|||||||
| 'neo-institute-context'
|
| 'neo-institute-context'
|
||||||
| 'auth-service'
|
| 'auth-service'
|
||||||
| 'graph-service'
|
| 'graph-service'
|
||||||
|
| 'graph-sidebar'
|
||||||
| 'registration-service'
|
| 'registration-service'
|
||||||
| 'provisioning-service'
|
| 'provisioning-service'
|
||||||
| 'snapshot-service'
|
| 'snapshot-service'
|
||||||
@@ -110,7 +111,9 @@ export type LogCategory =
|
|||||||
| 'auth-service'
|
| 'auth-service'
|
||||||
| 'user-context'
|
| 'user-context'
|
||||||
| 'neo-user-context'
|
| 'neo-user-context'
|
||||||
| 'neo-institute-context';
|
| 'neo-institute-context'
|
||||||
|
| 'cc-live-transcription'
|
||||||
|
| 'search-service';
|
||||||
|
|
||||||
interface LogConfig {
|
interface LogConfig {
|
||||||
enabled: boolean; // Master switch to turn logging on/off
|
enabled: boolean; // Master switch to turn logging on/off
|
||||||
@@ -338,6 +341,7 @@ logger.setConfig({
|
|||||||
'user-context',
|
'user-context',
|
||||||
'neo-user-context',
|
'neo-user-context',
|
||||||
'neo-institute-context',
|
'neo-institute-context',
|
||||||
|
'search-service'
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type DeviceType = 'desktop' | 'tablet' | 'phone' | 'iwb';
|
||||||
|
|
||||||
|
function detectDeviceType(): DeviceType {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const touchPoints = navigator.maxTouchPoints ?? 0;
|
||||||
|
const hasTouch = touchPoints > 0 || 'ontouchstart' in window;
|
||||||
|
|
||||||
|
if (width >= 1280 && !hasTouch) return 'desktop';
|
||||||
|
if (width >= 768 && hasTouch) return 'tablet';
|
||||||
|
if (width < 768) return 'phone';
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cc_device_type';
|
||||||
|
|
||||||
|
export function useDeviceContext() {
|
||||||
|
const [deviceType, setDeviceTypeState] = useState<DeviceType>(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY) as DeviceType | null;
|
||||||
|
if (stored && ['desktop', 'tablet', 'phone', 'iwb'].includes(stored)) return stored;
|
||||||
|
return detectDeviceType();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, deviceType);
|
||||||
|
}, [deviceType]);
|
||||||
|
|
||||||
|
const setDeviceType = (type: DeviceType) => {
|
||||||
|
setDeviceTypeState(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTouch = deviceType === 'tablet' || deviceType === 'phone';
|
||||||
|
const isMobileLayout = deviceType === 'phone';
|
||||||
|
|
||||||
|
return { deviceType, setDeviceType, isTouch, isMobileLayout };
|
||||||
|
}
|
||||||
+4
-5
@@ -1,7 +1,6 @@
|
|||||||
/* src/index.css */
|
/* src/index.css */
|
||||||
@tailwind base;
|
/* Tailwind removed — utility classes provided by tailwind-compat.css shim */
|
||||||
@tailwind components;
|
@import './tailwind-compat.css';
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
html,
|
html,
|
||||||
@@ -152,10 +151,10 @@ body {
|
|||||||
|
|
||||||
.login-role-header {
|
.login-role-header {
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
color: #1976d2 !important;
|
color: var(--cc-action-primary) !important;
|
||||||
margin-bottom: 24px !important;
|
margin-bottom: 24px !important;
|
||||||
padding-bottom: 8px !important;
|
padding-bottom: 8px !important;
|
||||||
border-bottom: 2px solid #1976d2 !important;
|
border-bottom: 2px solid var(--cc-action-primary) !important;
|
||||||
width: fit-content !important;
|
width: fit-content !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+188
-158
@@ -7,6 +7,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
IconButton,
|
IconButton,
|
||||||
Box,
|
Box,
|
||||||
|
Chip,
|
||||||
useTheme,
|
useTheme,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@@ -15,48 +16,47 @@ import {
|
|||||||
Divider
|
Divider
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import {
|
import Login from '@mui/icons-material/Login';
|
||||||
Login as LoginIcon,
|
import Logout from '@mui/icons-material/Logout';
|
||||||
Logout as LogoutIcon,
|
import Teacher from '@mui/icons-material/School';
|
||||||
School as TeacherIcon,
|
import Student from '@mui/icons-material/Person';
|
||||||
Person as StudentIcon,
|
import Calendar from '@mui/icons-material/CalendarToday';
|
||||||
Dashboard as TLDrawDevIcon,
|
import TeacherPlanner from '@mui/icons-material/Assignment';
|
||||||
Build as DevToolsIcon,
|
import ExamMarker from '@mui/icons-material/AssignmentTurnedIn';
|
||||||
Groups as MultiplayerIcon,
|
import Settings from '@mui/icons-material/Settings';
|
||||||
CalendarToday as CalendarIcon,
|
import Search from '@mui/icons-material/Search';
|
||||||
Assignment as TeacherPlannerIcon,
|
import Admin from '@mui/icons-material/AdminPanelSettings';
|
||||||
AssignmentTurnedIn as ExamMarkerIcon,
|
import Home from '@mui/icons-material/Home';
|
||||||
Settings as SettingsIcon,
|
import Schedule from '@mui/icons-material/Schedule';
|
||||||
Search as SearchIcon,
|
import Class from '@mui/icons-material/Class';
|
||||||
AdminPanelSettings as AdminIcon,
|
import Book from '@mui/icons-material/Book';
|
||||||
Home as HomeIcon
|
import Enrollment from '@mui/icons-material/HowToReg';
|
||||||
} from '@mui/icons-material';
|
import Lessons from '@mui/icons-material/EventNote';
|
||||||
|
import LessonPlans from '@mui/icons-material/LibraryBooks';
|
||||||
|
import People from '@mui/icons-material/People';
|
||||||
|
import SchoolSettings from '@mui/icons-material/Tune';
|
||||||
import { HEADER_HEIGHT } from './Layout';
|
import { HEADER_HEIGHT } from './Layout';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { GraphNavigator } from '../components/navigation/GraphNavigator';
|
import { GraphNavigator } from '../components/navigation/GraphNavigator';
|
||||||
|
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut, bootstrapData } = useAuth();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
|
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
|
||||||
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
|
|
||||||
const showGraphNavigation = location.pathname === '/single-player';
|
const showGraphNavigation = location.pathname === '/single-player';
|
||||||
|
const isPlatformAdmin = bootstrapData?.permissions?.platform_admin ?? false;
|
||||||
|
const schoolRole = bootstrapData?.active_institute?.membership_role ?? null;
|
||||||
|
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
|
||||||
|
|
||||||
// Update authentication state whenever user changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newAuthState = !!user;
|
setIsAuthenticated(!!user);
|
||||||
setIsAuthenticated(newAuthState);
|
}, [user]);
|
||||||
logger.debug('user-context', '🔄 User state changed in header', {
|
|
||||||
hasUser: newAuthState,
|
|
||||||
userId: user?.id,
|
|
||||||
userEmail: user?.email,
|
|
||||||
userState: newAuthState ? 'logged-in' : 'logged-out',
|
|
||||||
isAdmin
|
|
||||||
});
|
|
||||||
}, [user, isAdmin]);
|
|
||||||
|
|
||||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
@@ -80,7 +80,6 @@ const Header: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
logger.debug('auth-service', '🔄 Signing out user', { userId: user?.id });
|
logger.debug('auth-service', '🔄 Signing out user', { userId: user?.id });
|
||||||
await signOut();
|
await signOut();
|
||||||
// Clear local state immediately
|
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
logger.debug('auth-service', '✅ User signed out');
|
logger.debug('auth-service', '✅ User signed out');
|
||||||
@@ -103,73 +102,59 @@ const Header: React.FC = () => {
|
|||||||
<Toolbar sx={{
|
<Toolbar sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
minHeight: `${HEADER_HEIGHT}px !important`,
|
alignItems: 'center',
|
||||||
height: `${HEADER_HEIGHT}px`,
|
height: '100%'
|
||||||
gap: 2,
|
|
||||||
px: { xs: 1, sm: 2 }
|
|
||||||
}}>
|
}}>
|
||||||
<Box sx={{
|
{showGraphNavigation && (
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
minWidth: { xs: 'auto', sm: '200px' }
|
|
||||||
}}>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
component="div"
|
|
||||||
className="app-title"
|
|
||||||
sx={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
'&:hover': {
|
|
||||||
color: theme.palette.primary.main
|
|
||||||
},
|
|
||||||
fontSize: { xs: '1rem', sm: '1.25rem' }
|
|
||||||
}}
|
|
||||||
onClick={() => navigate(isAuthenticated ? '/dashboard' : '/')}
|
|
||||||
>
|
|
||||||
ClassroomCopilot
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
visibility: showGraphNavigation ? 'visible' : 'hidden',
|
|
||||||
width: {
|
|
||||||
xs: 'calc(100% - 160px)', // More space for menu and title
|
|
||||||
sm: 'calc(100% - 200px)', // Standard spacing
|
|
||||||
md: 'auto' // Full width on medium and up
|
|
||||||
},
|
|
||||||
maxWidth: '800px',
|
|
||||||
'& .navigation-controls': {
|
|
||||||
display: { xs: 'none', sm: 'flex' }
|
|
||||||
},
|
|
||||||
'& .context-section': {
|
|
||||||
display: { xs: 'none', md: 'flex' }
|
|
||||||
},
|
|
||||||
'& .context-toggle': {
|
|
||||||
display: 'flex' // Always show the profile/institute toggle
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<GraphNavigator />
|
<GraphNavigator />
|
||||||
</Box>
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
flexGrow: showGraphNavigation ? 0 : 1,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1
|
||||||
|
}}
|
||||||
|
onClick={() => handleNavigation('/')}
|
||||||
|
>
|
||||||
|
<Home sx={{ color: theme.palette.primary.main }} />
|
||||||
|
Classroom Copilot
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{isAuthenticated && bootstrapData?.onboarding?.next_step && bootstrapData.onboarding.next_step !== 'ready' && (
|
||||||
|
<Chip
|
||||||
|
label={bootstrapData.onboarding.next_step.replace(/_/g, ' ')}
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
sx={{ cursor: 'pointer', display: { xs: 'none', sm: 'flex' } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
display: { xs: 'none', sm: 'block' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.email}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
minWidth: { xs: 'auto', sm: '200px' },
|
|
||||||
ml: 'auto'
|
|
||||||
}}>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
className="menu-button"
|
|
||||||
color="inherit"
|
color="inherit"
|
||||||
|
aria-label="menu"
|
||||||
onClick={handleMenuOpen}
|
onClick={handleMenuOpen}
|
||||||
edge="end"
|
|
||||||
sx={{
|
sx={{
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
borderRadius: 1,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: theme.palette.action.hover
|
bgcolor: theme.palette.action.hover
|
||||||
}
|
}
|
||||||
@@ -177,101 +162,146 @@ const Header: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={Boolean(anchorEl)}
|
open={Boolean(anchorEl)}
|
||||||
onClose={handleMenuClose}
|
onClose={handleMenuClose}
|
||||||
slotProps={{
|
PaperProps={{
|
||||||
paper: {
|
sx: {
|
||||||
elevation: 3,
|
width: 280,
|
||||||
sx: {
|
maxHeight: '80vh'
|
||||||
bgcolor: theme.palette.background.paper,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
minWidth: '240px'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Navigation
|
||||||
|
</Typography>
|
||||||
|
|
||||||
{isAuthenticated ? [
|
{isAuthenticated ? [
|
||||||
<MenuItem key="dashboard" onClick={() => handleNavigation('/dashboard')}>
|
// Home
|
||||||
|
<MenuItem key="home" onClick={() => handleNavigation('/')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<HomeIcon />
|
<Home />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Dashboard" />
|
<ListItemText primary="Home" />
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<Divider key="dashboard-divider" />,
|
<Divider key="home-divider" />,
|
||||||
// Development Tools Section
|
|
||||||
<MenuItem key="tldraw" onClick={() => handleNavigation('/tldraw-dev')}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<TLDrawDevIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="TLDraw Dev" />
|
|
||||||
</MenuItem>,
|
|
||||||
<MenuItem key="dev" onClick={() => handleNavigation('/dev')}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<DevToolsIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Dev Tools" />
|
|
||||||
</MenuItem>,
|
|
||||||
<Divider key="dev-divider" />,
|
|
||||||
|
|
||||||
// Main Features Section
|
// My Work Section
|
||||||
<MenuItem key="multiplayer" onClick={() => handleNavigation('/multiplayer')}>
|
<Typography
|
||||||
|
key="work-header"
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
My Work
|
||||||
|
</Typography>,
|
||||||
|
<MenuItem key="my-classes" onClick={() => handleNavigation('/my-classes')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<MultiplayerIcon />
|
<Book />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Multiplayer" />
|
<ListItemText
|
||||||
|
primary="My Classes"
|
||||||
|
secondary="Manage your classes"
|
||||||
|
/>
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<MenuItem key="calendar" onClick={() => handleNavigation('/calendar')}>
|
<MenuItem key="my-lessons" onClick={() => handleNavigation('/my-lessons')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<CalendarIcon />
|
<Lessons />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Calendar" />
|
<ListItemText
|
||||||
|
primary="My Lessons"
|
||||||
|
secondary="Weekly lesson view"
|
||||||
|
/>
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<MenuItem key="planner" onClick={() => handleNavigation('/teacher-planner')}>
|
<MenuItem key="lesson-plans" onClick={() => handleNavigation('/lesson-plans')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<TeacherPlannerIcon />
|
<LessonPlans />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Teacher Planner" />
|
<ListItemText
|
||||||
|
primary="Lesson Plans"
|
||||||
|
secondary="Plan and manage lessons"
|
||||||
|
/>
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<MenuItem key="exam" onClick={() => handleNavigation('/exam-marker')}>
|
<Divider key="work-divider" />,
|
||||||
<ListItemIcon>
|
|
||||||
<ExamMarkerIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Exam Marker" />
|
|
||||||
</MenuItem>,
|
|
||||||
<Divider key="features-divider" />,
|
|
||||||
|
|
||||||
// Utilities Section
|
// School Admin Section
|
||||||
<MenuItem key="settings" onClick={() => handleNavigation('/settings')}>
|
...(isSchoolAdmin ? [
|
||||||
<ListItemIcon>
|
<Typography
|
||||||
<SettingsIcon />
|
key="school-admin-header"
|
||||||
</ListItemIcon>
|
variant="subtitle2"
|
||||||
<ListItemText primary="Settings" />
|
sx={{
|
||||||
</MenuItem>,
|
px: 2, py: 1,
|
||||||
<MenuItem key="search" onClick={() => handleNavigation('/search')}>
|
color: theme.palette.primary.main,
|
||||||
<ListItemIcon>
|
fontWeight: 600,
|
||||||
<SearchIcon />
|
letterSpacing: '0.5px',
|
||||||
</ListItemIcon>
|
textTransform: 'uppercase',
|
||||||
<ListItemText primary="Search" />
|
fontSize: '0.75rem',
|
||||||
</MenuItem>,
|
}}
|
||||||
|
>
|
||||||
|
School Admin
|
||||||
|
</Typography>,
|
||||||
|
<MenuItem key="school-settings" onClick={() => handleNavigation('/school-settings')}>
|
||||||
|
<ListItemIcon><SchoolSettings /></ListItemIcon>
|
||||||
|
<ListItemText primary="School Settings" secondary="Calendar, overview" />
|
||||||
|
</MenuItem>,
|
||||||
|
<MenuItem key="staff-manager" onClick={() => handleNavigation('/staff-manager')}>
|
||||||
|
<ListItemIcon><People /></ListItemIcon>
|
||||||
|
<ListItemText primary="Staff Manager" secondary="Invite & manage teachers" />
|
||||||
|
</MenuItem>,
|
||||||
|
<MenuItem key="student-manager" onClick={() => handleNavigation('/student-manager')}>
|
||||||
|
<ListItemIcon><Teacher /></ListItemIcon>
|
||||||
|
<ListItemText primary="Student Manager" secondary="Invite & manage students" />
|
||||||
|
</MenuItem>,
|
||||||
|
<Divider key="school-admin-divider" />,
|
||||||
|
] : []),
|
||||||
|
|
||||||
// Admin Section
|
// Platform Admin Section
|
||||||
...(isAdmin ? [
|
...(isPlatformAdmin ? [
|
||||||
<Divider key="admin-divider" />,
|
<Divider key="admin-divider" />,
|
||||||
|
<Typography
|
||||||
|
key="admin-header"
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
color: theme.palette.error.main,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Administration
|
||||||
|
</Typography>,
|
||||||
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
|
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon><Admin /></ListItemIcon>
|
||||||
<AdminIcon />
|
<ListItemText primary="Platform Admin" secondary="Schools & system stats" />
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Admin Dashboard" />
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
] : []),
|
] : []),
|
||||||
|
|
||||||
// Authentication Section
|
|
||||||
<Divider key="auth-divider" />,
|
|
||||||
<MenuItem key="signout" onClick={handleSignOut}>
|
<MenuItem key="signout" onClick={handleSignOut}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<LogoutIcon />
|
<Logout />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Sign Out" />
|
<ListItemText primary="Sign Out" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -279,14 +309,14 @@ const Header: React.FC = () => {
|
|||||||
// Authentication Section for Non-authenticated Users
|
// Authentication Section for Non-authenticated Users
|
||||||
<MenuItem key="signin" onClick={() => handleNavigation('/login')}>
|
<MenuItem key="signin" onClick={() => handleNavigation('/login')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<LoginIcon />
|
<Login />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Sign In" />
|
<ListItemText primary="Sign In" />
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<Divider key="signup-divider" />,
|
<Divider key="signup-divider" />,
|
||||||
<MenuItem key="teacher-signup" onClick={() => handleSignupNavigation('teacher')}>
|
<MenuItem key="teacher-signup" onClick={() => handleSignupNavigation('teacher')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<TeacherIcon />
|
<Teacher />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary="Sign up as Teacher"
|
primary="Sign up as Teacher"
|
||||||
@@ -295,7 +325,7 @@ const Header: React.FC = () => {
|
|||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<MenuItem key="student-signup" onClick={() => handleSignupNavigation('student')}>
|
<MenuItem key="student-signup" onClick={() => handleSignupNavigation('student')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<StudentIcon />
|
<Student />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary="Sign up as Student"
|
primary="Sign up as Student"
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||||
|
Table, TableHead, TableBody, TableRow, TableCell,
|
||||||
|
Card, CardContent, Grid, IconButton, Tooltip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Refresh, School, People, EventNote, HourglassEmpty } from '@mui/icons-material';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
interface SchoolEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
urn: string | null;
|
||||||
|
website: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
neo4j_uuid_string: string | null;
|
||||||
|
staff_count: number;
|
||||||
|
student_count: number;
|
||||||
|
has_calendar: boolean;
|
||||||
|
pending_invitations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
schools: number;
|
||||||
|
profiles: number;
|
||||||
|
taught_lessons: number;
|
||||||
|
pending_invitations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ElementType }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||||
|
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlatformAdminPage: React.FC = () => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [schools, setSchools] = useState<SchoolEntry[]>([]);
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [schoolsRes, statsRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/admin/schools`, { headers }).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE}/admin/stats`, { headers }).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
if (schoolsRes.status === 'ok') setSchools(schoolsRes.schools || []);
|
||||||
|
else setError(schoolsRes.detail || 'Failed to load schools');
|
||||||
|
if (statsRes.status === 'ok') setStats(statsRes);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>Platform Admin</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Classroom Copilot — system overview</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Tooltip title="Refresh">
|
||||||
|
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => navigate('/dashboard')}>← Dashboard</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={6} sm={3}><StatCard label="Schools" value={stats.schools} icon={School} /></Grid>
|
||||||
|
<Grid item xs={6} sm={3}><StatCard label="Profiles" value={stats.profiles} icon={People} /></Grid>
|
||||||
|
<Grid item xs={6} sm={3}><StatCard label="Taught Lessons" value={stats.taught_lessons} icon={EventNote} /></Grid>
|
||||||
|
<Grid item xs={6} sm={3}><StatCard label="Pending Invites" value={stats.pending_invitations} icon={HourglassEmpty} /></Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||||
|
) : schools.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No schools registered.</Typography>
|
||||||
|
) : (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>School</TableCell>
|
||||||
|
<TableCell>URN</TableCell>
|
||||||
|
<TableCell>Staff</TableCell>
|
||||||
|
<TableCell>Students</TableCell>
|
||||||
|
<TableCell>Calendar</TableCell>
|
||||||
|
<TableCell>Invites</TableCell>
|
||||||
|
<TableCell>Neo4j</TableCell>
|
||||||
|
<TableCell>Registered</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{schools.map(s => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>{s.name}</TableCell>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
|
||||||
|
{s.urn || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{s.staff_count}</TableCell>
|
||||||
|
<TableCell>{s.student_count}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={s.has_calendar ? 'Yes' : 'No'}
|
||||||
|
size="small"
|
||||||
|
color={s.has_calendar ? 'success' : 'default'}
|
||||||
|
sx={{ fontSize: '0.65rem', height: 18 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{s.pending_invitations > 0
|
||||||
|
? <Chip label={s.pending_invitations} size="small" color="warning" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||||
|
: <Typography variant="caption" sx={{ color: 'text.disabled' }}>—</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={s.neo4j_uuid_string ? 'Provisioned' : 'Pending'}
|
||||||
|
size="small"
|
||||||
|
color={s.neo4j_uuid_string ? 'success' : 'warning'}
|
||||||
|
sx={{ fontSize: '0.65rem', height: 18 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||||
|
{new Date(s.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformAdminPage;
|
||||||
@@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
|
|||||||
import { EmailCredentials } from '../../services/auth/authService';
|
import { EmailCredentials } from '../../services/auth/authService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const getLoginErrorMessage = (error: unknown): string => {
|
||||||
|
const status = (error as { status?: number } | null)?.status;
|
||||||
|
const code = (error as { code?: string } | null)?.code;
|
||||||
|
|
||||||
|
if (code === 'email_not_confirmed' || status === 401 && code === 'invalid_grant') {
|
||||||
|
return 'Please confirm your email before logging in';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 401 || code === 'invalid_grant') {
|
||||||
|
return 'Invalid email or password';
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error ? error.message : 'Login failed';
|
||||||
|
};
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, signIn } = useAuth();
|
const { user, signIn, isAuthResolving } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
logger.debug('login-page', '🔍 Login page loaded', {
|
logger.debug('login-page', '🔍 Login page loaded', {
|
||||||
hasUser: !!user
|
hasUser: !!user,
|
||||||
|
isAuthResolving
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (!isAuthResolving && user) {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, isAuthResolving, navigate]);
|
||||||
|
|
||||||
const handleLogin = async (credentials: EmailCredentials) => {
|
const handleLogin = async (credentials: EmailCredentials) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await signIn(credentials.email, credentials.password);
|
await signIn(credentials.email, credentials.password);
|
||||||
navigate('/dashboard');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('login-page', '❌ Login failed', error);
|
logger.error('login-page', '❌ Login failed', error);
|
||||||
setError(error instanceof Error ? error.message : 'Login failed');
|
const message = getLoginErrorMessage(error);
|
||||||
|
setError(message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user) {
|
if (user && !isAuthResolving) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { EmailSignupForm } from './EmailSignupForm';
|
|||||||
import { EmailCredentials } from '../../services/auth/authService';
|
import { EmailCredentials } from '../../services/auth/authService';
|
||||||
import { RegistrationService } from '../../services/auth/registrationService';
|
import { RegistrationService } from '../../services/auth/registrationService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import MicrosoftIcon from '@mui/icons-material/Microsoft';
|
import MicrosoftIcon from '@mui/icons-material/Microsoft.js';
|
||||||
|
|
||||||
const SignupPage: React.FC = () => {
|
const SignupPage: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
CloudUpload as UploadIcon,
|
CloudUpload as UploadIcon,
|
||||||
|
Upload,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
FolderOpen as FolderOpenIcon,
|
FolderOpen as FolderOpenIcon,
|
||||||
Description as FileIcon,
|
Description as FileIcon,
|
||||||
@@ -39,8 +40,12 @@ import {
|
|||||||
PlayArrow as ProcessIcon,
|
PlayArrow as ProcessIcon,
|
||||||
CheckCircle as SuccessIcon,
|
CheckCircle as SuccessIcon,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
Info as InfoIcon
|
Info as InfoIcon,
|
||||||
|
InsertDriveFile,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
const FolderOpen = FolderOpenIcon;
|
||||||
|
const Refresh = RefreshIcon;
|
||||||
|
const Info = InfoIcon;
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import {
|
import {
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
@@ -123,7 +128,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dirInputRef = useRef<HTMLInputElement>(null);
|
const dirInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080';
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
|
||||||
const apiFetch = useCallback(async (url: string, init?: { method?: string; body?: FormData | string; headers?: Record<string, string> }) => {
|
const apiFetch = useCallback(async (url: string, init?: { method?: string; body?: FormData | string; headers?: Record<string, string> }) => {
|
||||||
const session = await supabase.auth.getSession();
|
const session = await supabase.auth.getSession();
|
||||||
@@ -454,7 +459,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="Upload Controls"
|
title="Upload Controls"
|
||||||
avatar={<UploadIcon />}
|
avatar={<Upload />}
|
||||||
/>
|
/>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
@@ -497,7 +502,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<UploadIcon />}
|
startIcon={<Upload />}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={!selectedCabinet || loading}
|
disabled={!selectedCabinet || loading}
|
||||||
>
|
>
|
||||||
@@ -515,7 +520,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<FolderOpenIcon />}
|
startIcon={<FolderOpen />}
|
||||||
onClick={handleDirectoryPicker}
|
onClick={handleDirectoryPicker}
|
||||||
disabled={!selectedCabinet || loading}
|
disabled={!selectedCabinet || loading}
|
||||||
>
|
>
|
||||||
@@ -524,7 +529,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<RefreshIcon />}
|
startIcon={<Refresh />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
@@ -554,7 +559,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="System Info"
|
title="System Info"
|
||||||
avatar={<InfoIcon />}
|
avatar={<Info />}
|
||||||
/>
|
/>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
@@ -633,7 +638,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
title={
|
title={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<FileIcon />
|
<InsertDriveFile />
|
||||||
<Typography variant="h6">
|
<Typography variant="h6">
|
||||||
Files {pagination && `(${pagination.total_count} total)`}
|
Files {pagination && `(${pagination.total_count} total)`}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -748,7 +753,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{file.is_directory ? <FolderIcon /> : <FileIcon />}
|
{file.is_directory ? <FolderIcon /> : <InsertDriveFile />}
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={
|
primary={
|
||||||
@@ -812,7 +817,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
<Dialog open={showUploadDialog} onClose={() => !isUploading && setShowUploadDialog(false)} maxWidth="md" fullWidth>
|
<Dialog open={showUploadDialog} onClose={() => !isUploading && setShowUploadDialog(false)} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
<FolderOpenIcon />
|
<FolderOpen />
|
||||||
Directory Upload Progress
|
Directory Upload Progress
|
||||||
{isUploading && <LinearProgress sx={{ flexGrow: 1, ml: 2 }} />}
|
{isUploading && <LinearProgress sx={{ flexGrow: 1, ml: 2 }} />}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -823,7 +828,7 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
<Alert severity="info" sx={{ mb: 2 }}>
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<strong>{directoryStats.fileCount} files</strong> in{' '}
|
<strong>{directoryStats.fileCount} files</strong> in{' '}
|
||||||
<strong>{directoryStats.directoryCount} folders</strong><br/>
|
<strong>{directoryStats.directoryCount} folders</strong><br />
|
||||||
Total size: <strong>{directoryStats.formattedSize}</strong>
|
Total size: <strong>{directoryStats.formattedSize}</strong>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -843,12 +848,12 @@ const SimpleUploadTest: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
color={
|
color={
|
||||||
item.status === 'done' ? 'success' :
|
item.status === 'done' ? 'success' :
|
||||||
item.status === 'error' ? 'error' :
|
item.status === 'error' ? 'error' :
|
||||||
item.status === 'uploading' ? 'primary' : 'default'
|
item.status === 'uploading' ? 'primary' : 'default'
|
||||||
}
|
}
|
||||||
icon={
|
icon={
|
||||||
item.status === 'done' ? <SuccessIcon /> :
|
item.status === 'done' ? <SuccessIcon /> :
|
||||||
item.status === 'error' ? <ErrorIcon /> : undefined
|
item.status === 'error' ? <ErrorIcon /> : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||||
|
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||||
|
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { ExamTemplate } from '../../types/exam.types';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> = {
|
||||||
|
draft: 'warning',
|
||||||
|
ready: 'success',
|
||||||
|
archived: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExamDashboardPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { bootstrapData } = useAuth();
|
||||||
|
const instituteId = bootstrapData?.active_institute?.id ?? undefined;
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setTemplates(await examRepository.listTemplates());
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.warn('cc-exam-marker', 'Failed to load templates', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load, instituteId]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await examRepository.createTemplate({
|
||||||
|
title: title.trim(),
|
||||||
|
subject: subject.trim() || undefined,
|
||||||
|
institute_id: instituteId,
|
||||||
|
});
|
||||||
|
setCreateOpen(false);
|
||||||
|
setTitle('');
|
||||||
|
setSubject('');
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Create template failed', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (id: string, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
try {
|
||||||
|
await examRepository.archiveTemplate(id);
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h3" component="h1" gutterBottom>
|
||||||
|
Exam Marker
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||||
|
Build a template for an exam paper, then run marking batches against your classes.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<Paper variant="outlined" sx={{ p: 6, textAlign: 'center' }}>
|
||||||
|
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Create your first template to start mapping an exam paper.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||||
|
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||||
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
||||||
|
<Tooltip title="Archive">
|
||||||
|
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
||||||
|
<ArchiveIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{t.subject && (
|
||||||
|
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
||||||
|
)}
|
||||||
|
{t.exam_code && (
|
||||||
|
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(t.updated_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>New exam template</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||||
|
{saving ? 'Creating…' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamDashboardPage;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
|
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
|
import SaveIcon from '@mui/icons-material/Save'
|
||||||
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
||||||
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
|
import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||||
|
import { logger } from '../../../debugConfig'
|
||||||
|
import { examRepository } from '../../../services/exam/examRepository'
|
||||||
|
import type { ExamTemplateDetail } from '../../../types/exam.types'
|
||||||
|
import { ExamCanvasShapeModel, PAGE_WIDTH, type PdfPageLayout, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||||
|
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, PDF_PAGE_SHAPE_TYPE, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes'
|
||||||
|
import { renderPdfBackdrop, replacePdfPageShapes } from './pdfLoader'
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete user regions. The PDF backdrop is locked.', color: 'default' as const },
|
||||||
|
{ id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const },
|
||||||
|
{ id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const },
|
||||||
|
{ id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const },
|
||||||
|
{ id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const },
|
||||||
|
{ id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const },
|
||||||
|
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const },
|
||||||
|
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const },
|
||||||
|
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||||
|
if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' }
|
||||||
|
return { conflict: false, message: detail ?? err.message }
|
||||||
|
}
|
||||||
|
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripShapePrefix(id: string) {
|
||||||
|
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||||
|
const kind = shapeTypeToKind(shape.type)
|
||||||
|
if (!kind) return null
|
||||||
|
const s = shape as ExamCanvasTLShape
|
||||||
|
return {
|
||||||
|
id: s.props.domainId || stripShapePrefix(s.id),
|
||||||
|
kind,
|
||||||
|
x: Number(s.x ?? 0),
|
||||||
|
y: Number(s.y ?? 0),
|
||||||
|
w: Number(s.props.w ?? 1),
|
||||||
|
h: Number(s.props.h ?? 1),
|
||||||
|
label: s.props.label,
|
||||||
|
maxMarks: s.props.maxMarks,
|
||||||
|
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
||||||
|
contextType: s.props.contextType,
|
||||||
|
questionId: s.props.questionId ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||||
|
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||||
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
|
if (!models.length) return
|
||||||
|
editor.createShapes(models.map((m) => ({
|
||||||
|
id: createShapeId(m.id),
|
||||||
|
type: SHAPE_TYPES[m.kind],
|
||||||
|
x: m.x,
|
||||||
|
y: m.y,
|
||||||
|
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, domainId: m.id, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId },
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
function newDomainId() {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||||
|
return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedGuide(editor: Editor) {
|
||||||
|
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
||||||
|
if (current.length) return
|
||||||
|
editor.createShapes([
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 start' } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 end' } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', domainId: newDomainId(), label: 'Q1(a)', maxMarks: 3 } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', domainId: newDomainId(), label: 'Response', responseForm: 'lines' } },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamTemplateSetupInner: React.FC = () => {
|
||||||
|
const { templateId } = useParams<{ templateId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const theme = useTheme()
|
||||||
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
const pageLayoutsRef = useRef<PdfPageLayout[]>([])
|
||||||
|
const hydratingRef = useRef(false)
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false)
|
||||||
|
const [pdfStatus, setPdfStatus] = useState<string>('PDF not loaded')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
|
const [activeTool, setActiveTool] = useState('select')
|
||||||
|
|
||||||
|
const loadPdf = useCallback(async (id: string, editor: Editor) => {
|
||||||
|
setPdfLoading(true)
|
||||||
|
setPdfStatus('Loading PDF…')
|
||||||
|
try {
|
||||||
|
const result = await renderPdfBackdrop(id)
|
||||||
|
hydratingRef.current = true
|
||||||
|
replacePdfPageShapes(editor, result.pages)
|
||||||
|
pageLayoutsRef.current = result.pageLayouts
|
||||||
|
setPdfStatus(`${result.pages.length} PDF page${result.pages.length === 1 ? '' : 's'} loaded`)
|
||||||
|
editor.zoomToFit()
|
||||||
|
} catch (e) {
|
||||||
|
pageLayoutsRef.current = []
|
||||||
|
const msg = apiMessage(e).message
|
||||||
|
setPdfStatus(`PDF unavailable: ${msg}`)
|
||||||
|
logger.warn('cc-exam-marker', 'Template setup PDF backdrop failed', { templateId: id, message: msg })
|
||||||
|
} finally {
|
||||||
|
hydratingRef.current = false
|
||||||
|
setPdfLoading(false)
|
||||||
|
setDirty(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!templateId) return
|
||||||
|
setLoading(true); setError(null); setConflict(null)
|
||||||
|
try {
|
||||||
|
const detail = await examRepository.getTemplate(templateId)
|
||||||
|
setTemplate(detail)
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (editor) {
|
||||||
|
hydratingRef.current = true
|
||||||
|
loadShapes(editor, shapesFromTemplate(detail))
|
||||||
|
hydratingRef.current = false
|
||||||
|
await loadPdf(templateId, editor)
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = apiMessage(e).message
|
||||||
|
logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg })
|
||||||
|
setError(msg)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [loadPdf, templateId])
|
||||||
|
|
||||||
|
useEffect(() => { void load() }, [load])
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor || !templateId || !template) return
|
||||||
|
setSaving(true); setError(null); setConflict(null)
|
||||||
|
try {
|
||||||
|
const shapes = editor.getCurrentPageShapes().filter((s) => s.type !== PDF_PAGE_SHAPE_TYPE).map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||||
|
const payload = serializeCanvasShapes(template, shapes, pageLayoutsRef.current)
|
||||||
|
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||||
|
setTemplate(saved)
|
||||||
|
hydratingRef.current = true
|
||||||
|
loadShapes(editor, shapesFromTemplate(saved))
|
||||||
|
hydratingRef.current = false
|
||||||
|
setDirty(false)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = apiMessage(e)
|
||||||
|
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
|
||||||
|
logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [template, templateId])
|
||||||
|
|
||||||
|
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||||
|
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
||||||
|
color={tool.color}
|
||||||
|
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
|
||||||
|
setActiveTool(tool.id)
|
||||||
|
}}
|
||||||
|
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
|
||||||
|
>
|
||||||
|
{tool.label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)), [activeTool])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default' }}>
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
||||||
|
<Tldraw
|
||||||
|
shapeUtils={examCanvasShapeUtils as any}
|
||||||
|
bindingUtils={defaultBindingUtils}
|
||||||
|
tools={examCanvasTools as any}
|
||||||
|
hideUi
|
||||||
|
inferDarkMode={theme.palette.mode === 'dark'}
|
||||||
|
autoFocus
|
||||||
|
onMount={(editor) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
|
||||||
|
editor.store.listen((entry) => {
|
||||||
|
const changedPdfOnly = Object.values(entry.changes.added).every((record: any) => record?.type === PDF_PAGE_SHAPE_TYPE)
|
||||||
|
if (!hydratingRef.current && !changedPdfOnly) setDirty(true)
|
||||||
|
}, { scope: 'document' })
|
||||||
|
if (template) {
|
||||||
|
hydratingRef.current = true
|
||||||
|
loadShapes(editor, shapesFromTemplate(template))
|
||||||
|
hydratingRef.current = false
|
||||||
|
if (templateId) void loadPdf(templateId, editor)
|
||||||
|
} else seedGuide(editor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper elevation={8} sx={{ position: 'absolute', top: 12, left: 12, right: 12, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} size="small">Back</Button>
|
||||||
|
<Divider orientation="vertical" flexItem />
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · real PDF backdrop is locked; drawn regions persist with PDF-derived page numbers.</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip size="small" icon={<PictureAsPdfIcon />} color={pdfStatus.includes('loaded') ? 'info' : pdfStatus.includes('unavailable') ? 'warning' : 'default'} label={pdfLoading ? 'Loading PDF…' : pdfStatus} />
|
||||||
|
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||||
|
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper elevation={8} sx={{ position: 'absolute', top: 92, left: 12, p: 1.25, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Stack spacing={1}>{toolButtons}</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
1) Use the visible PDF pages as the paper coordinate system. 2) Draw top/bottom Boundary lines, Part boxes, then regions over the real paper. 3) Save derives page numbers from the PDF page under each shape and reloads them at the same canvas location.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}
|
||||||
|
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 86, right: 16, maxWidth: 560 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
||||||
|
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamTemplateSetupPage: React.FC = () => (
|
||||||
|
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
|
||||||
|
<ExamTemplateSetupInner />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default ExamTemplateSetupPage
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
||||||
|
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||||
|
|
||||||
|
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
|
|
||||||
|
export const SHAPE_TYPES = {
|
||||||
|
boundary: 'exam-boundary',
|
||||||
|
part: 'exam-part',
|
||||||
|
response: 'exam-region-response',
|
||||||
|
context: 'exam-region-context',
|
||||||
|
question_number: 'exam-region-question-number',
|
||||||
|
mark_area: 'exam-region-mark-area',
|
||||||
|
reference: 'exam-region-reference',
|
||||||
|
furniture: 'exam-region-furniture',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PdfPageTLShape = TLBaseBoxShape<typeof PDF_PAGE_SHAPE_TYPE, {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
src: string
|
||||||
|
pageNumber: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type ExamCanvasTLShape = TLBaseBoxShape<typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES], {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
label: string
|
||||||
|
kind: ExamCanvasShapeKind
|
||||||
|
domainId: string
|
||||||
|
maxMarks?: number
|
||||||
|
responseForm?: string
|
||||||
|
contextType?: string
|
||||||
|
questionId?: string | null
|
||||||
|
}>
|
||||||
|
|
||||||
|
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
|
||||||
|
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
|
||||||
|
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
|
||||||
|
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
|
||||||
|
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
|
||||||
|
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
|
||||||
|
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
|
||||||
|
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
|
||||||
|
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDomainId() {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||||
|
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
||||||
|
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
|
const kind = shape.props.kind
|
||||||
|
const p = palette[kind] ?? palette.response
|
||||||
|
const isBoundary = kind === 'boundary'
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
|
||||||
|
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
|
background: isBoundary ? 'transparent' : p.fill, color: p.stroke, fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||||
|
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? 'none' : '0 10px 22px rgba(15,23,42,0.08)', overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
|
||||||
|
{shape.props.label || p.label}
|
||||||
|
</span>
|
||||||
|
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>}
|
||||||
|
</div>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||||
|
const p = palette[kind]
|
||||||
|
return { w, h, label: p.label, kind, domainId: makeDomainId(), responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
class PdfPageUtil extends BaseBoxShapeUtil<PdfPageTLShape> {
|
||||||
|
static override type = PDF_PAGE_SHAPE_TYPE
|
||||||
|
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
|
||||||
|
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
|
||||||
|
override canEdit() { return false }
|
||||||
|
override canResize() { return false }
|
||||||
|
override hideResizeHandles() { return true }
|
||||||
|
override hideRotateHandle() { return true }
|
||||||
|
override component(shape: PdfPageTLShape) {
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||||
|
<img
|
||||||
|
alt={`PDF page ${shape.props.pageNumber}`}
|
||||||
|
src={shape.props.src}
|
||||||
|
draggable={false}
|
||||||
|
style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', boxShadow: '0 12px 26px rgba(15,23,42,0.22)', background: '#fff' }}
|
||||||
|
/>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, domainId: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||||
|
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||||
|
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } }
|
||||||
|
|
||||||
|
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
|
||||||
|
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
|
||||||
|
class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response }
|
||||||
|
class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context }
|
||||||
|
class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number }
|
||||||
|
class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area }
|
||||||
|
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference }
|
||||||
|
class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture }
|
||||||
|
|
||||||
|
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
|
||||||
|
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
|
||||||
|
|
||||||
|
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||||
|
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||||
|
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Editor } from '@tldraw/tldraw'
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist'
|
||||||
|
import pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs?url'
|
||||||
|
|
||||||
|
import { examRepository } from '../../../services/exam/examRepository'
|
||||||
|
import { PAGE_GAP, PAGE_WIDTH, type PdfPageLayout } from '../../../utils/exam-canvas/model'
|
||||||
|
import { PDF_PAGE_SHAPE_TYPE, type PdfPageTLShape } from './examCanvasShapes'
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||||
|
|
||||||
|
export interface PdfBackdropPage {
|
||||||
|
page: number
|
||||||
|
src: string
|
||||||
|
layout: PdfPageLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfBackdropResult {
|
||||||
|
pages: PdfBackdropPage[]
|
||||||
|
pageLayouts: PdfPageLayout[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPdfBackdrop(templateId: string): Promise<PdfBackdropResult> {
|
||||||
|
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(bytes) })
|
||||||
|
const pdf = await loadingTask.promise
|
||||||
|
const pages: PdfBackdropPage[] = []
|
||||||
|
let y = 0
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdf.getPage(pageNumber)
|
||||||
|
const nativeViewport = page.getViewport({ scale: 1 })
|
||||||
|
const layoutScale = PAGE_WIDTH / nativeViewport.width
|
||||||
|
const renderScale = layoutScale * Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
const renderViewport = page.getViewport({ scale: renderScale })
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('Could not create 2D canvas for PDF rendering')
|
||||||
|
canvas.width = Math.ceil(renderViewport.width)
|
||||||
|
canvas.height = Math.ceil(renderViewport.height)
|
||||||
|
await page.render({ canvasContext: context, viewport: renderViewport }).promise
|
||||||
|
|
||||||
|
const layout: PdfPageLayout = {
|
||||||
|
page: pageNumber,
|
||||||
|
x: 0,
|
||||||
|
y,
|
||||||
|
w: PAGE_WIDTH,
|
||||||
|
h: nativeViewport.height * layoutScale,
|
||||||
|
}
|
||||||
|
pages.push({ page: pageNumber, src: canvas.toDataURL('image/jpeg', 0.9), layout })
|
||||||
|
y += layout.h + PAGE_GAP
|
||||||
|
}
|
||||||
|
|
||||||
|
await pdf.destroy()
|
||||||
|
return { pages, pageLayouts: pages.map((p) => p.layout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replacePdfPageShapes(editor: Editor, pages: PdfBackdropPage[]) {
|
||||||
|
const old = editor.getCurrentPageShapes().filter((shape) => shape.type === PDF_PAGE_SHAPE_TYPE).map((shape) => shape.id)
|
||||||
|
if (old.length) editor.deleteShapes(old)
|
||||||
|
if (!pages.length) return
|
||||||
|
|
||||||
|
editor.createShapes(pages.map((page) => ({
|
||||||
|
id: `shape:pdf-page-${page.page}` as PdfPageTLShape['id'],
|
||||||
|
type: PDF_PAGE_SHAPE_TYPE,
|
||||||
|
x: page.layout.x,
|
||||||
|
y: page.layout.y,
|
||||||
|
isLocked: true,
|
||||||
|
props: {
|
||||||
|
w: page.layout.w,
|
||||||
|
h: page.layout.h,
|
||||||
|
src: page.src,
|
||||||
|
pageNumber: page.page,
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, CircularProgress, Alert, Chip, Divider,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
||||||
|
Autocomplete, IconButton, Tooltip, Tabs, Tab, Avatar,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Profile { id: string; full_name: string; display_name?: string; email: string; }
|
||||||
|
interface ClassTeacher { teacher_id: string; is_primary: boolean; can_edit: boolean; profile: Profile; }
|
||||||
|
interface ClassStudent { student_id: string; status: string; enrolled_at: string; profile: Profile; }
|
||||||
|
interface EnrollmentRequest { id: string; student_id: string; status: string; created_at: string; profile: Profile; }
|
||||||
|
|
||||||
|
interface ClassDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
class_code?: string;
|
||||||
|
subject?: string;
|
||||||
|
year_group?: string;
|
||||||
|
description?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
teachers: ClassTeacher[];
|
||||||
|
students: ClassStudent[];
|
||||||
|
enrollment_requests: EnrollmentRequest[];
|
||||||
|
student_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initials(name: string) {
|
||||||
|
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Add Student Dialog ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AddStudentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (studentId: string) => Promise<void>;
|
||||||
|
accessToken: string;
|
||||||
|
existingIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: AddStudentDialogProps) {
|
||||||
|
const [allStudents, setAllStudents] = useState<Profile[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Profile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`${API_BASE}/classes/school/students`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setAllStudents((d.students || []).filter((s: Profile) => !existingIds.has(s.id))))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open, accessToken, existingIds]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
setSaving(true);
|
||||||
|
await onAdd(selected.id);
|
||||||
|
setSaving(false);
|
||||||
|
setSelected(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Add Student to Class</DialogTitle>
|
||||||
|
<DialogContent sx={{ pt: 2 }}>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Autocomplete
|
||||||
|
options={allStudents}
|
||||||
|
getOptionLabel={o => `${o.full_name} (${o.email})`}
|
||||||
|
value={selected}
|
||||||
|
onChange={(_, v) => setSelected(v)}
|
||||||
|
renderInput={params => (
|
||||||
|
<TextField {...params} label="Search students" size="small" autoFocus />
|
||||||
|
)}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!selected || saving}
|
||||||
|
startIcon={saving ? <CircularProgress size={16} /> : <PersonAdd />}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ClassDetailPage: React.FC = () => {
|
||||||
|
const { classId } = useParams<{ classId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { accessToken, user, bootstrapData } = useAuth();
|
||||||
|
|
||||||
|
const [cls, setCls] = useState<ClassDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!accessToken || !classId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (clsRes.id) setCls(clsRes);
|
||||||
|
else setError(clsRes.detail || 'Class not found');
|
||||||
|
const role = bootstrapData?.active_institute?.membership_role || '';
|
||||||
|
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken, classId, bootstrapData]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const apiPost = async (path: string, body?: object) => {
|
||||||
|
const r = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiDelete = async (path: string) => {
|
||||||
|
await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiPatch = async (path: string, body: object) => {
|
||||||
|
const r = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddStudent = async (studentId: string) => {
|
||||||
|
setActionError(null);
|
||||||
|
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
|
||||||
|
if (res.status === 'ok') load();
|
||||||
|
else setActionError(res.detail || 'Failed to add student');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveStudent = async (studentId: string) => {
|
||||||
|
setActionError(null);
|
||||||
|
await apiDelete(`/classes/${classId}/students/${studentId}`);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
||||||
|
setActionError(null);
|
||||||
|
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
||||||
|
if (res.status === 'ok') load();
|
||||||
|
else setActionError(res.detail || 'Action failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !cls) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Alert severity="error">{error || 'Class not found'}</Alert>
|
||||||
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/classes')}>
|
||||||
|
Back to Classes
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingStudentIds = new Set(cls.students.map(s => s.student_id));
|
||||||
|
const pendingCount = cls.enrollment_requests.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<ArrowBack />}
|
||||||
|
onClick={() => navigate('/classes')}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
Back to Classes
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={700}>{cls.name}</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{cls.class_code && <Chip label={cls.class_code} size="small" />}
|
||||||
|
{cls.subject && <Chip label={cls.subject} size="small" variant="outlined" />}
|
||||||
|
{cls.year_group && <Chip label={`Y${cls.year_group}`} size="small" variant="outlined" />}
|
||||||
|
<Chip
|
||||||
|
label={cls.is_active ? 'Active' : 'Inactive'}
|
||||||
|
size="small"
|
||||||
|
color={cls.is_active ? 'success' : 'default'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{cls.description && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{cls.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<School sx={{ color: 'primary.main', fontSize: 40, opacity: 0.3 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{actionError && (
|
||||||
|
<Alert severity="error" onClose={() => setActionError(null)} sx={{ mb: 2 }}>
|
||||||
|
{actionError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
|
<Tab label={`Students (${cls.student_count})`} />
|
||||||
|
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
|
||||||
|
<Tab label={`Teachers (${cls.teachers.length})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Students tab */}
|
||||||
|
{tab === 0 && (
|
||||||
|
<Box>
|
||||||
|
{isAdmin && (
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<PersonAdd />}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
>
|
||||||
|
Add Student
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{cls.students.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
No students enrolled yet
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{cls.students.map(s => (
|
||||||
|
<Box
|
||||||
|
key={s.student_id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||||
|
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'primary.light' }}>
|
||||||
|
{initials(s.profile?.full_name || '?')}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{s.profile?.full_name || s.student_id}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{s.profile?.email}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{isAdmin && (
|
||||||
|
<Tooltip title="Remove student">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleRemoveStudent(s.student_id)}
|
||||||
|
>
|
||||||
|
<PersonRemove fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enrollment requests tab */}
|
||||||
|
{tab === 1 && (
|
||||||
|
<Box>
|
||||||
|
{cls.enrollment_requests.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
No pending enrollment requests
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{cls.enrollment_requests.map(req => (
|
||||||
|
<Box
|
||||||
|
key={req.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||||
|
p: 1.5, border: '1px solid', borderColor: 'warning.light',
|
||||||
|
borderRadius: 1, bgcolor: 'warning.50',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'warning.light' }}>
|
||||||
|
{initials(req.profile?.full_name || '?')}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{req.profile?.full_name || req.student_id}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{req.profile?.email} · requested{' '}
|
||||||
|
{new Date(req.created_at).toLocaleDateString('en-GB')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{isAdmin && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<Tooltip title="Approve">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
onClick={() => handleEnrollmentResponse(req.id, 'approve')}
|
||||||
|
>
|
||||||
|
<CheckCircle fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Reject">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleEnrollmentResponse(req.id, 'reject')}
|
||||||
|
>
|
||||||
|
<Cancel fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Teachers tab */}
|
||||||
|
{tab === 2 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{cls.teachers.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
No teachers assigned
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
cls.teachers.map(t => (
|
||||||
|
<Box
|
||||||
|
key={t.teacher_id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||||
|
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'secondary.light' }}>
|
||||||
|
{initials(t.profile?.full_name || '?')}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{t.profile?.full_name || t.teacher_id}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{t.profile?.email}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{t.is_primary && (
|
||||||
|
<Chip label="Primary" size="small" color="primary" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddStudentDialog
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
onAdd={handleAddStudent}
|
||||||
|
accessToken={accessToken || ''}
|
||||||
|
existingIds={existingStudentIds}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClassDetailPage;
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Add, CalendarToday, FilterList, MenuBook, People, Search } from '@mui/icons-material';
|
||||||
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
|
||||||
|
const ClassesPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { profile } = useUser();
|
||||||
|
const {
|
||||||
|
classes,
|
||||||
|
totalCount,
|
||||||
|
classesLoading,
|
||||||
|
classesError,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
filterSubject,
|
||||||
|
filterSchoolYear,
|
||||||
|
filterAcademicTerm,
|
||||||
|
searchQuery,
|
||||||
|
fetchClasses,
|
||||||
|
setFilterSubject,
|
||||||
|
setFilterSchoolYear,
|
||||||
|
setFilterAcademicTerm,
|
||||||
|
setSearchQuery,
|
||||||
|
setPage,
|
||||||
|
clearFilters,
|
||||||
|
} = useTimetableStore();
|
||||||
|
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClasses({
|
||||||
|
subject: filterSubject || undefined,
|
||||||
|
school_year: filterSchoolYear || undefined,
|
||||||
|
academic_term: filterAcademicTerm || undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
skip: currentPage * pageSize,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
|
}, [filterSubject, filterSchoolYear, filterAcademicTerm, searchQuery, currentPage, pageSize]);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(0);
|
||||||
|
fetchClasses({
|
||||||
|
subject: filterSubject || undefined,
|
||||||
|
school_year: filterSchoolYear || undefined,
|
||||||
|
academic_term: filterAcademicTerm || undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
skip: 0,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Classes</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Manage classes, timetables, and student enrollments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{(profile?.role === 'teacher' || profile?.role === 'admin') && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/timetable/classes/create')}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Add size={20} />
|
||||||
|
Create Class
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search classes by name or subject..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<FilterList size={20} />
|
||||||
|
Filters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Subject</label>
|
||||||
|
<select
|
||||||
|
value={filterSubject || ''}
|
||||||
|
onChange={(e) => setFilterSubject(e.target.value || null)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Subjects</option>
|
||||||
|
<option value="Mathematics">Mathematics</option>
|
||||||
|
<option value="Science">Science</option>
|
||||||
|
<option value="English">English</option>
|
||||||
|
<option value="History">History</option>
|
||||||
|
<option value="Geography">Geography</option>
|
||||||
|
<option value="Art">Art</option>
|
||||||
|
<option value="Music">Music</option>
|
||||||
|
<option value="Physical Education">Physical Education</option>
|
||||||
|
<option value="Computer Science">Computer Science</option>
|
||||||
|
<option value="Foreign Language">Foreign Language</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">School Year</label>
|
||||||
|
<select
|
||||||
|
value={filterSchoolYear || ''}
|
||||||
|
onChange={(e) => setFilterSchoolYear(e.target.value || null)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Years</option>
|
||||||
|
<option value="2025-2026">2025-2026</option>
|
||||||
|
<option value="2024-2025">2024-2025</option>
|
||||||
|
<option value="2023-2024">2023-2024</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Term</label>
|
||||||
|
<select
|
||||||
|
value={filterAcademicTerm || ''}
|
||||||
|
onChange={(e) => setFilterAcademicTerm(e.target.value || null)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Terms</option>
|
||||||
|
<option value="Fall">Fall</option>
|
||||||
|
<option value="Spring">Spring</option>
|
||||||
|
<option value="Summer">Summer</option>
|
||||||
|
<option value="Full Year">Full Year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{classesError && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{classesError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Classes Grid */}
|
||||||
|
{classesLoading ? (
|
||||||
|
<div className="flex justify-center items-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
) : classes.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
||||||
|
<MenuBook className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">No classes found</h3>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
{searchQuery || filterSubject || filterSchoolYear
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'Create your first class to get started'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{classes.map((cls) => (
|
||||||
|
<Link
|
||||||
|
key={cls.id}
|
||||||
|
to={`/timetable/classes/${cls.id}`}
|
||||||
|
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md hover:border-blue-300 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="p-2 bg-blue-50 rounded-lg">
|
||||||
|
<MenuBook className="text-blue-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs font-medium rounded-full">
|
||||||
|
{cls.subject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">{cls.name}</h3>
|
||||||
|
<p className="text-gray-500 text-sm mb-4">
|
||||||
|
{cls.school_year} • {cls.academic_term}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<People size={16} />
|
||||||
|
{cls.student_count} students
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CalendarToday size={16} />
|
||||||
|
{cls.timetable_count} timetables
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center items-center gap-2 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(currentPage - 1)}
|
||||||
|
disabled={currentPage === 0}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
Page {currentPage + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(currentPage + 1)}
|
||||||
|
disabled={currentPage >= totalPages - 1}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClassesPage;
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Check, Close, FilterList, KeyboardArrowDown, People, PersonAdd, Search } from '@mui/icons-material';
|
||||||
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
|
import Modal from '../../components/common/Modal';
|
||||||
|
|
||||||
|
interface RequestFilters {
|
||||||
|
class_id?: string;
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'all';
|
||||||
|
search: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnrollmentRequestsPage: React.FC = () => {
|
||||||
|
const {
|
||||||
|
myClasses,
|
||||||
|
enrollmentRequests,
|
||||||
|
enrollmentRequestsLoading,
|
||||||
|
enrollmentRequestsError,
|
||||||
|
fetchMyClasses,
|
||||||
|
fetchEnrollmentRequests,
|
||||||
|
respondToEnrollmentRequest,
|
||||||
|
} = useTimetableStore();
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<RequestFilters>({
|
||||||
|
status: 'pending',
|
||||||
|
search: '',
|
||||||
|
});
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<string | null>(null);
|
||||||
|
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
|
// Get classes where user is teacher
|
||||||
|
const teachingClasses = myClasses.filter(c => c.role === 'teacher');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyClasses();
|
||||||
|
fetchEnrollmentRequests({
|
||||||
|
class_id: filters.class_id,
|
||||||
|
status: filters.status === 'all' ? undefined : filters.status,
|
||||||
|
});
|
||||||
|
}, [fetchMyClasses, fetchEnrollmentRequests, filters.class_id, filters.status]);
|
||||||
|
|
||||||
|
const handleRespond = async () => {
|
||||||
|
if (!selectedRequest || !actionType) return;
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
await respondToEnrollmentRequest(selectedRequest, {
|
||||||
|
status: actionType,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
setSelectedRequest(null);
|
||||||
|
setActionType(null);
|
||||||
|
setNotes('');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter requests by search
|
||||||
|
const filteredRequests = enrollmentRequests.filter(request => {
|
||||||
|
if (!filters.search) return true;
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
return (
|
||||||
|
request.student?.first_name?.toLowerCase().includes(searchLower) ||
|
||||||
|
request.student?.last_name?.toLowerCase().includes(searchLower) ||
|
||||||
|
request.student?.email?.toLowerCase().includes(searchLower) ||
|
||||||
|
request.class?.name?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs font-medium rounded-full">Pending</span>;
|
||||||
|
case 'approved':
|
||||||
|
return <span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">Approved</span>;
|
||||||
|
case 'rejected':
|
||||||
|
return <span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded-full">Rejected</span>;
|
||||||
|
default:
|
||||||
|
return <span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded-full">{status}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (enrollmentRequestsLoading && enrollmentRequests.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Enrollment Requests</h1>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
Manage student enrollment requests for your classes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{/* Class Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FilterList size={18} className="text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={filters.class_id || ''}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, class_id: e.target.value || undefined }))}
|
||||||
|
className="border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Classes</option>
|
||||||
|
{teachingClasses.map(cls => (
|
||||||
|
<option key={cls.class_id} value={cls.class_id}>
|
||||||
|
{cls.class?.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value as RequestFilters['status'] }))}
|
||||||
|
className="border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={18} className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by student name or email..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{enrollmentRequestsError && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
<p>Error loading requests: {enrollmentRequestsError}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchEnrollmentRequests({
|
||||||
|
class_id: filters.class_id,
|
||||||
|
status: filters.status === 'all' ? undefined : filters.status,
|
||||||
|
})}
|
||||||
|
className="mt-2 text-sm font-medium text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Requests Table */}
|
||||||
|
{filteredRequests.length > 0 ? (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Student
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Class
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Requested
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{filteredRequests.map((request) => (
|
||||||
|
<tr key={request.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center">
|
||||||
|
<PersonAdd size={18} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{request.student?.first_name} {request.student?.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{request.student?.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{request.class?.name}</div>
|
||||||
|
{request.class?.code && (
|
||||||
|
<div className="text-sm text-gray-500">{request.class.code}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(request.requested_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{getStatusBadge(request.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
{request.status === 'pending' && (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRequest(request.id);
|
||||||
|
setActionType('approve');
|
||||||
|
setNotes('');
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200"
|
||||||
|
>
|
||||||
|
<Check size={14} className="mr-1" />
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRequest(request.id);
|
||||||
|
setActionType('reject');
|
||||||
|
setNotes('');
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200"
|
||||||
|
>
|
||||||
|
<Close size={14} className="mr-1" />
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{request.status !== 'pending' && (
|
||||||
|
<span className="text-gray-400 text-xs">
|
||||||
|
{request.responded_at && `Responded ${new Date(request.responded_at).toLocaleDateString()}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 bg-white rounded-lg shadow">
|
||||||
|
<People className="mx-auto h-12 w-12 text-gray-300" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-gray-900">No enrollment requests</h3>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
{filters.status === 'pending'
|
||||||
|
? 'There are no pending enrollment requests for your classes.'
|
||||||
|
: 'No enrollment requests match your filters.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Response Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!selectedRequest && !!actionType}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedRequest(null);
|
||||||
|
setActionType(null);
|
||||||
|
setNotes('');
|
||||||
|
}}
|
||||||
|
title={actionType === 'approve' ? 'Approve Enrollment' : 'Reject Enrollment'}
|
||||||
|
maxWidth="max-w-md"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{actionType === 'approve'
|
||||||
|
? 'Are you sure you want to approve this enrollment request? The student will be added to the class.'
|
||||||
|
: 'Are you sure you want to reject this enrollment request?'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="notes" className="block text-sm font-medium text-gray-700">
|
||||||
|
Notes (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder={actionType === 'approve' ? 'Add a welcome message...' : 'Reason for rejection...'}
|
||||||
|
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRequest(null);
|
||||||
|
setActionType(null);
|
||||||
|
setNotes('');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRespond}
|
||||||
|
disabled={processing}
|
||||||
|
className={`px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white ${
|
||||||
|
actionType === 'approve'
|
||||||
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
|
: 'bg-red-600 hover:bg-red-700'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{processing ? 'Processing...' : actionType === 'approve' ? 'Approve' : 'Reject'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnrollmentRequestsPage;
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { AccessTime, ArrowBack, CalendarToday, Cancel, CheckCircle, Delete, Description, Edit, LocationOn, MenuBook, People } from '@mui/icons-material';
|
||||||
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import Modal from '../../components/common/Modal';
|
||||||
|
|
||||||
|
const LessonPage: React.FC = () => {
|
||||||
|
const { lessonId } = useParams<{ lessonId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { profile } = useUser();
|
||||||
|
const {
|
||||||
|
currentLesson,
|
||||||
|
currentTimetable,
|
||||||
|
currentClass,
|
||||||
|
lessonDetailLoading,
|
||||||
|
lessonDetailError,
|
||||||
|
fetchLessonDetail,
|
||||||
|
deleteLesson,
|
||||||
|
updateAttendance,
|
||||||
|
clearCurrentLesson,
|
||||||
|
} = useTimetableStore();
|
||||||
|
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [attendanceUpdating, setAttendanceUpdating] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lessonId) {
|
||||||
|
fetchLessonDetail(lessonId);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
clearCurrentLesson();
|
||||||
|
};
|
||||||
|
}, [lessonId, fetchLessonDetail, clearCurrentLesson]);
|
||||||
|
|
||||||
|
const handleDeleteLesson = async () => {
|
||||||
|
if (!lessonId) return;
|
||||||
|
if (confirm('Are you sure you want to delete this lesson?')) {
|
||||||
|
await deleteLesson(lessonId);
|
||||||
|
navigate(`/timetable/timetables/${currentTimetable?.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttendanceUpdate = async (studentId: string, status: 'present' | 'absent' | 'late' | 'excused') => {
|
||||||
|
if (!lessonId) return;
|
||||||
|
setAttendanceUpdating(studentId);
|
||||||
|
await updateAttendance(lessonId, studentId, status);
|
||||||
|
setAttendanceUpdating(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'present': return 'bg-green-100 text-green-700';
|
||||||
|
case 'absent': return 'bg-red-100 text-red-700';
|
||||||
|
case 'late': return 'bg-yellow-100 text-yellow-700';
|
||||||
|
case 'excused': return 'bg-blue-100 text-blue-700';
|
||||||
|
default: return 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTeacher = profile?.role === 'teacher' || profile?.role === 'admin';
|
||||||
|
const isOwner = currentClass?.teacher_id === profile?.id;
|
||||||
|
const canManage = isTeacher && isOwner;
|
||||||
|
|
||||||
|
if (lessonDetailLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lessonDetailError) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-700">{lessonDetailError}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentLesson) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500">Lesson not found</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="mb-4">
|
||||||
|
<ol className="flex items-center space-x-2 text-sm text-gray-500">
|
||||||
|
<li>
|
||||||
|
<Link to="/timetable" className="hover:text-blue-600">Timetable</Link>
|
||||||
|
</li>
|
||||||
|
<li>/</li>
|
||||||
|
<li>
|
||||||
|
<Link to={`/timetable/classes/${currentClass?.id}`} className="hover:text-blue-600">
|
||||||
|
{currentClass?.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>/</li>
|
||||||
|
<li>
|
||||||
|
<Link to={`/timetable/timetables/${currentTimetable?.id}`} className="hover:text-blue-600">
|
||||||
|
{currentTimetable?.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>/</li>
|
||||||
|
<li className="text-gray-900 font-medium">Lesson</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to={`/timetable/timetables/${currentTimetable?.id}`}
|
||||||
|
className="inline-flex items-center text-gray-500 hover:text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
<ArrowBack size={16} className="mr-1" />
|
||||||
|
Back to Timetable
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">{currentLesson.title}</h1>
|
||||||
|
{currentLesson.description && (
|
||||||
|
<p className="text-gray-600 mt-1">{currentLesson.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditModalOpen(true)}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit size={16} className="mr-2" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteLesson}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Delete size={16} className="mr-2" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lesson Details */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||||
|
<AccessTime size={20} className="mr-2 text-blue-600" />
|
||||||
|
Time & Location
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CalendarToday size={18} className="text-gray-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Date</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{format(parseISO(currentLesson.start_time), 'EEEE, MMMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AccessTime size={18} className="text-gray-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Time</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{format(parseISO(currentLesson.start_time), 'HH:mm')} - {format(parseISO(currentLesson.end_time), 'HH:mm')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(currentLesson.location || currentLesson.room) && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<LocationOn size={18} className="text-gray-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Location</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{currentLesson.location}
|
||||||
|
{currentLesson.location && currentLesson.room && ' - '}
|
||||||
|
{currentLesson.room && `Room ${currentLesson.room}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||||
|
<MenuBook size={20} className="mr-2 text-green-600" />
|
||||||
|
Class Information
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Class</p>
|
||||||
|
<p className="font-medium text-gray-900">{currentClass?.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Timetable</p>
|
||||||
|
<p className="font-medium text-gray-900">{currentTimetable?.title}</p>
|
||||||
|
</div>
|
||||||
|
{currentLesson.subject && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Subject</p>
|
||||||
|
<p className="font-medium text-gray-900">{currentLesson.subject}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attendance Section */}
|
||||||
|
{currentLesson.attendance && currentLesson.attendance.length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||||
|
<People size={20} className="mr-2 text-purple-600" />
|
||||||
|
Attendance
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{currentLesson.attendance.map((record) => (
|
||||||
|
<div key={record.student_id} className="px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-medium">
|
||||||
|
{record.student?.first_name?.[0]}{record.student?.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{record.student?.first_name} {record.student?.last_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">{record.student?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={record.status}
|
||||||
|
onChange={(e) => handleAttendanceUpdate(record.student_id, e.target.value as any)}
|
||||||
|
disabled={attendanceUpdating === record.student_id}
|
||||||
|
className="text-sm border border-gray-300 rounded-md px-2 py-1 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="present">Present</option>
|
||||||
|
<option value="absent">Absent</option>
|
||||||
|
<option value="late">Late</option>
|
||||||
|
<option value="excused">Excused</option>
|
||||||
|
</select>
|
||||||
|
{attendanceUpdating === record.student_id && (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded capitalize ${getStatusColor(record.status)}`}>
|
||||||
|
{record.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal Placeholder */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onClose={() => setIsEditModalOpen(false)}
|
||||||
|
title="Edit Lesson"
|
||||||
|
>
|
||||||
|
<p className="text-gray-600">Lesson editing form would go here...</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditModalOpen(false)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonPage;
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, CircularProgress, Alert, Chip, TextField,
|
||||||
|
IconButton, Tooltip, Divider, Paper, Select, MenuItem, FormControl,
|
||||||
|
InputLabel, Switch, FormControlLabel, Dialog, DialogTitle,
|
||||||
|
DialogContent, DialogActions,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ArrowBack, Add, Delete, AutoAwesome, DragIndicator, Save,
|
||||||
|
LibraryBooks, LinkOff,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Objective {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
bloom_level?: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: string;
|
||||||
|
section: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
duration_minutes?: number;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LessonPlan {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string | null;
|
||||||
|
year_group: string | null;
|
||||||
|
duration_minutes: number | null;
|
||||||
|
context_notes: string | null;
|
||||||
|
objectives: Objective[];
|
||||||
|
activities: Activity[];
|
||||||
|
is_public: boolean;
|
||||||
|
creator_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BLOOM_LEVELS = ['remember', 'understand', 'apply', 'analyse', 'evaluate', 'create'];
|
||||||
|
const SECTION_SUGGESTIONS = ['Starter', 'Main', 'Plenary', 'Extension', 'Assessment', 'Discussion'];
|
||||||
|
const BLOOM_COLORS: Record<string, string> = {
|
||||||
|
remember: '#90caf9', understand: '#a5d6a7', apply: '#fff176',
|
||||||
|
analyse: '#ffcc80', evaluate: '#ef9a9a', create: '#ce93d8',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── AI Suggest button ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SuggestButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<Tooltip title="AI suggest">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={onClick} disabled={loading} color="primary" sx={{ p: 0.5 }}>
|
||||||
|
{loading ? <CircularProgress size={16} /> : <AutoAwesome fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LessonPlanDetailPage: React.FC = () => {
|
||||||
|
const { planId } = useParams<{ planId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
|
||||||
|
const [plan, setPlan] = useState<LessonPlan | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [suggestingField, setSuggestingField] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Local editable state (mirrors plan)
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [yearGroup, setYearGroup] = useState('');
|
||||||
|
const [duration, setDuration] = useState('');
|
||||||
|
const [contextNotes, setContextNotes] = useState('');
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
|
const [objectives, setObjectives] = useState<Objective[]>([]);
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
|
||||||
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!accessToken || !planId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/lessons/plans/${planId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.id) {
|
||||||
|
setPlan(data);
|
||||||
|
setTitle(data.title || '');
|
||||||
|
setSubject(data.subject || '');
|
||||||
|
setYearGroup(data.year_group || '');
|
||||||
|
setDuration(data.duration_minutes?.toString() || '');
|
||||||
|
setContextNotes(data.context_notes || '');
|
||||||
|
setIsPublic(data.is_public || false);
|
||||||
|
setObjectives(data.objectives || []);
|
||||||
|
setActivities(data.activities || []);
|
||||||
|
} else {
|
||||||
|
setError(data.detail || 'Plan not found');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken, planId]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// ── Save ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const save = useCallback(async (
|
||||||
|
overrides?: Partial<{ title: string; subject: string; year_group: string; duration_minutes: number | null; context_notes: string; is_public: boolean; objectives: Objective[]; activities: Activity[] }>
|
||||||
|
) => {
|
||||||
|
if (!accessToken || !planId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/lessons/plans/${planId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
subject: subject || null,
|
||||||
|
year_group: yearGroup || null,
|
||||||
|
duration_minutes: duration ? parseInt(duration, 10) : null,
|
||||||
|
context_notes: contextNotes || null,
|
||||||
|
is_public: isPublic,
|
||||||
|
objectives,
|
||||||
|
activities,
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [accessToken, planId, title, subject, yearGroup, duration, contextNotes, isPublic, objectives, activities]);
|
||||||
|
|
||||||
|
const scheduleAutoSave = useCallback(() => {
|
||||||
|
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||||
|
autoSaveTimer.current = setTimeout(() => save(), 1500);
|
||||||
|
}, [save]);
|
||||||
|
|
||||||
|
// ── Objectives ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const addObjective = () => {
|
||||||
|
const next: Objective = { id: crypto.randomUUID(), text: '', bloom_level: undefined, order: objectives.length };
|
||||||
|
setObjectives(prev => [...prev, next]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateObjective = (id: string, patch: Partial<Objective>) => {
|
||||||
|
setObjectives(prev => prev.map(o => o.id === id ? { ...o, ...patch } : o));
|
||||||
|
scheduleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeObjective = (id: string) => {
|
||||||
|
setObjectives(prev => prev.filter(o => o.id !== id).map((o, i) => ({ ...o, order: i })));
|
||||||
|
scheduleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Activities ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const addActivity = () => {
|
||||||
|
const next: Activity = {
|
||||||
|
id: crypto.randomUUID(), section: 'Main', title: '', description: '', order: activities.length,
|
||||||
|
};
|
||||||
|
setActivities(prev => [...prev, next]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateActivity = (id: string, patch: Partial<Activity>) => {
|
||||||
|
setActivities(prev => prev.map(a => a.id === id ? { ...a, ...patch } : a));
|
||||||
|
scheduleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeActivity = (id: string) => {
|
||||||
|
setActivities(prev => prev.filter(a => a.id !== id).map((a, i) => ({ ...a, order: i })));
|
||||||
|
scheduleAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AI Suggest ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const suggest = async (field: string, targetId?: string) => {
|
||||||
|
if (!accessToken || !planId) return;
|
||||||
|
const key = targetId ? `${field}_${targetId}` : field;
|
||||||
|
setSuggestingField(key);
|
||||||
|
try {
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
field,
|
||||||
|
context: contextNotes,
|
||||||
|
objective_texts: objectives.map(o => o.text).filter(Boolean),
|
||||||
|
};
|
||||||
|
if (field === 'activity_description' && targetId) {
|
||||||
|
const act = activities.find(a => a.id === targetId);
|
||||||
|
if (act) body.activity_section = act.section;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE}/lessons/plans/${planId}/suggest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.suggestion) {
|
||||||
|
if (field === 'objectives') {
|
||||||
|
addObjective();
|
||||||
|
setObjectives(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[updated.length - 1] = { ...updated[updated.length - 1], text: data.suggestion };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} else if (field === 'activity_description' && targetId) {
|
||||||
|
updateActivity(targetId, { description: data.suggestion });
|
||||||
|
} else if (field === 'title') {
|
||||||
|
setTitle(data.suggestion);
|
||||||
|
scheduleAutoSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError('AI suggest failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
setSuggestingField(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !plan) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/lesson-plans')}>
|
||||||
|
Back to Plans
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 860, mx: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Button size="small" startIcon={<ArrowBack />} onClick={() => navigate('/lesson-plans')}>
|
||||||
|
Lesson Plans
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{saved && <Typography variant="caption" color="success.main">Saved</Typography>}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={saving ? <CircularProgress size={14} /> : <Save />}
|
||||||
|
onClick={() => save()}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title row */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
|
||||||
|
<TextField
|
||||||
|
value={title}
|
||||||
|
onChange={e => { setTitle(e.target.value); scheduleAutoSave(); }}
|
||||||
|
variant="standard"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ style: { fontSize: '1.5rem', fontWeight: 700 } }}
|
||||||
|
placeholder="Lesson title…"
|
||||||
|
/>
|
||||||
|
<SuggestButton
|
||||||
|
onClick={() => suggest('title')}
|
||||||
|
loading={suggestingField === 'title'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 3, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
label="Subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={e => { setSubject(e.target.value); scheduleAutoSave(); }}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Year group"
|
||||||
|
value={yearGroup}
|
||||||
|
onChange={e => { setYearGroup(e.target.value); scheduleAutoSave(); }}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Duration (min)"
|
||||||
|
value={duration}
|
||||||
|
onChange={e => { setDuration(e.target.value); scheduleAutoSave(); }}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 1, max: 300 }}
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={isPublic}
|
||||||
|
onChange={e => { setIsPublic(e.target.checked); scheduleAutoSave(); }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="caption">Public</Typography>}
|
||||||
|
sx={{ ml: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Context notes */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 0.75 }}>
|
||||||
|
Context / notes
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
value={contextNotes}
|
||||||
|
onChange={e => { setContextNotes(e.target.value); scheduleAutoSave(); }}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Class context, prior knowledge, SEN considerations, links to curriculum…"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Objectives */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700}>
|
||||||
|
Learning objectives
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<SuggestButton
|
||||||
|
onClick={() => suggest('objectives')}
|
||||||
|
loading={suggestingField === 'objectives'}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Add objective">
|
||||||
|
<IconButton size="small" onClick={addObjective}>
|
||||||
|
<Add fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{objectives.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1.5, textAlign: 'center' }}>
|
||||||
|
No objectives yet — add one or use ✨ to suggest.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{objectives.map((obj, idx) => (
|
||||||
|
<Paper key={obj.id} variant="outlined" sx={{ p: 1.25 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<DragIndicator sx={{ color: 'text.disabled', mt: 0.5, flexShrink: 0 }} fontSize="small" />
|
||||||
|
<TextField
|
||||||
|
value={obj.text}
|
||||||
|
onChange={e => updateObjective(obj.id, { text: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
placeholder={`Objective ${idx + 1}…`}
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 120, flexShrink: 0 }}>
|
||||||
|
<Select
|
||||||
|
value={obj.bloom_level || ''}
|
||||||
|
onChange={e => updateObjective(obj.id, { bloom_level: e.target.value || undefined })}
|
||||||
|
displayEmpty
|
||||||
|
variant="standard"
|
||||||
|
renderValue={v => v ? (
|
||||||
|
<Chip
|
||||||
|
label={v as string}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem', bgcolor: BLOOM_COLORS[v as string] || 'grey.200' }}
|
||||||
|
/>
|
||||||
|
) : <Typography variant="caption" color="text.disabled">Bloom level</Typography>}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>None</em></MenuItem>
|
||||||
|
{BLOOM_LEVELS.map(l => (
|
||||||
|
<MenuItem key={l} value={l}>
|
||||||
|
<Chip
|
||||||
|
label={l}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem', bgcolor: BLOOM_COLORS[l] || 'grey.200' }}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<IconButton size="small" color="error" onClick={() => removeObjective(obj.id)} sx={{ flexShrink: 0, p: 0.25 }}>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Activities */}
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700}>
|
||||||
|
Activities
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Add activity">
|
||||||
|
<IconButton size="small" onClick={addActivity}>
|
||||||
|
<Add fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1.5, textAlign: 'center' }}>
|
||||||
|
No activities yet — add one to structure your lesson.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{activities.map((act, idx) => (
|
||||||
|
<Paper key={act.id} variant="outlined" sx={{ p: 1.5 }}>
|
||||||
|
{/* Activity header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<DragIndicator sx={{ color: 'text.disabled', flexShrink: 0 }} fontSize="small" />
|
||||||
|
|
||||||
|
{/* Section selector */}
|
||||||
|
<TextField
|
||||||
|
value={act.section}
|
||||||
|
onChange={e => updateActivity(act.id, { section: e.target.value })}
|
||||||
|
size="small"
|
||||||
|
label="Section"
|
||||||
|
sx={{ width: 140, flexShrink: 0 }}
|
||||||
|
select={false}
|
||||||
|
inputProps={{ list: `section-suggestions-${act.id}` }}
|
||||||
|
placeholder="Starter / Main…"
|
||||||
|
/>
|
||||||
|
<datalist id={`section-suggestions-${act.id}`}>
|
||||||
|
{SECTION_SUGGESTIONS.map(s => <option key={s} value={s} />)}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
value={act.title}
|
||||||
|
onChange={e => updateActivity(act.id, { title: e.target.value })}
|
||||||
|
size="small"
|
||||||
|
label="Activity title"
|
||||||
|
fullWidth
|
||||||
|
placeholder={`Activity ${idx + 1}…`}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
value={act.duration_minutes?.toString() || ''}
|
||||||
|
onChange={e => updateActivity(act.id, { duration_minutes: e.target.value ? parseInt(e.target.value, 10) : undefined })}
|
||||||
|
size="small"
|
||||||
|
label="Min"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
sx={{ width: 72, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<IconButton size="small" color="error" onClick={() => removeActivity(act.id)} sx={{ flexShrink: 0, p: 0.25 }}>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, pl: 3.5 }}>
|
||||||
|
<TextField
|
||||||
|
value={act.description}
|
||||||
|
onChange={e => updateActivity(act.id, { description: e.target.value })}
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
fullWidth
|
||||||
|
placeholder="Describe what the teacher and pupils do…"
|
||||||
|
/>
|
||||||
|
<SuggestButton
|
||||||
|
onClick={() => suggest('activity_description', act.id)}
|
||||||
|
loading={suggestingField === `activity_description_${act.id}`}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonPlanDetailPage;
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, CircularProgress, Alert, Chip, Card,
|
||||||
|
CardContent, CardActions, IconButton, Tooltip, TextField,
|
||||||
|
InputAdornment, MenuItem, Select, FormControl, InputLabel,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Add, Search, LibraryBooks, Edit, Delete, FilterList } from '@mui/icons-material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PlanSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string | null;
|
||||||
|
year_group: string | null;
|
||||||
|
duration_minutes: number | null;
|
||||||
|
objectives: { id: string; text: string; bloom_level?: string }[];
|
||||||
|
activities: { id: string; section: string; title: string }[];
|
||||||
|
is_public: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
creator_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create plan dialog ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CreateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (data: { title: string; subject?: string; year_group?: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreatePlanDialog({ open, onClose, onCreate }: CreateDialogProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [yearGroup, setYearGroup] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => { setTitle(''); setSubject(''); setYearGroup(''); };
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
await onCreate({ title: title.trim(), subject: subject || undefined, year_group: yearGroup || undefined });
|
||||||
|
setSaving(false);
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => { reset(); onClose(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>New Lesson Plan</DialogTitle>
|
||||||
|
<DialogContent sx={{ pt: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
placeholder="e.g. Introduction to Photosynthesis"
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={e => setSubject(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="e.g. Biology"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Year group"
|
||||||
|
value={yearGroup}
|
||||||
|
onChange={e => setYearGroup(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
placeholder="e.g. 9"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={handleClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!title.trim() || saving}
|
||||||
|
startIcon={saving ? <CircularProgress size={16} /> : <Add />}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plan card ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PlanCard({ plan, onOpen, onDelete }: {
|
||||||
|
plan: PlanSummary;
|
||||||
|
onOpen: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const objCount = plan.objectives?.length ?? 0;
|
||||||
|
const actCount = plan.activities?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
display: 'flex', flexDirection: 'column', cursor: 'pointer',
|
||||||
|
'&:hover': { borderColor: 'primary.main', boxShadow: 1 },
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
|
}}
|
||||||
|
onClick={() => onOpen(plan.id)}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ flex: 1, pb: 1 }}>
|
||||||
|
<Typography variant="body1" fontWeight={700} sx={{ mb: 0.5, lineHeight: 1.3 }}>
|
||||||
|
{plan.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mb: 1 }}>
|
||||||
|
{plan.subject && (
|
||||||
|
<Chip label={plan.subject} size="small" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
{plan.year_group && (
|
||||||
|
<Chip label={`Y${plan.year_group}`} size="small" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
{plan.duration_minutes && (
|
||||||
|
<Chip label={`${plan.duration_minutes}min`} size="small" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
{plan.is_public && (
|
||||||
|
<Chip label="Public" size="small" color="info" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{objCount} objective{objCount !== 1 ? 's' : ''} · {actCount} activit{actCount !== 1 ? 'ies' : 'y'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ pt: 0, px: 1.5, pb: 1.5, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<Tooltip title="Edit plan">
|
||||||
|
<IconButton size="small" onClick={() => onOpen(plan.id)}>
|
||||||
|
<Edit fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete plan">
|
||||||
|
<IconButton size="small" color="error" onClick={() => onDelete(plan.id)}>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LessonPlansPage: React.FC = () => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterSubject, setFilterSubject] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/lessons/plans`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data)) setPlans(data);
|
||||||
|
else setError(data.detail || 'Failed to load plans');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleCreate = async (body: { title: string; subject?: string; year_group?: string }) => {
|
||||||
|
const res = await fetch(`${API_BASE}/lessons/plans`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.id) {
|
||||||
|
navigate(`/lesson-plans/${data.id}`);
|
||||||
|
} else {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!window.confirm('Delete this lesson plan?')) return;
|
||||||
|
await fetch(`${API_BASE}/lessons/plans/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
setPlans(prev => prev.filter(p => p.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const subjects = Array.from(new Set(plans.map(p => p.subject).filter(Boolean) as string[])).sort();
|
||||||
|
|
||||||
|
const filtered = plans.filter(p => {
|
||||||
|
const matchSearch = !search || p.title.toLowerCase().includes(search.toLowerCase())
|
||||||
|
|| (p.subject || '').toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchSubject = !filterSubject || p.subject === filterSubject;
|
||||||
|
return matchSearch && matchSubject;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<LibraryBooks sx={{ color: 'primary.main', fontSize: 32 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={700}>Lesson Plans</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{plans.length} plan{plans.length !== 1 ? 's' : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
New Plan
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search plans…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment>,
|
||||||
|
}}
|
||||||
|
sx={{ width: 280 }}
|
||||||
|
/>
|
||||||
|
{subjects.length > 0 && (
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Subject</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Subject"
|
||||||
|
value={filterSubject}
|
||||||
|
onChange={e => setFilterSubject(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All subjects</MenuItem>
|
||||||
|
{subjects.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
{(search || filterSubject) && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<FilterList />}
|
||||||
|
onClick={() => { setSearch(''); setFilterSubject(''); }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8, color: 'text.secondary' }}>
|
||||||
|
<LibraryBooks sx={{ fontSize: 56, opacity: 0.2, mb: 1 }} />
|
||||||
|
<Typography variant="body1">
|
||||||
|
{plans.length === 0 ? 'No lesson plans yet.' : 'No plans match your filters.'}
|
||||||
|
</Typography>
|
||||||
|
{plans.length === 0 && (
|
||||||
|
<Button sx={{ mt: 2 }} variant="outlined" startIcon={<Add />} onClick={() => setCreateOpen(true)}>
|
||||||
|
Create your first plan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2 }}>
|
||||||
|
{filtered.map(plan => (
|
||||||
|
<PlanCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
onOpen={id => navigate(`/lesson-plans/${id}`)}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreatePlanDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonPlansPage;
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
|
||||||
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
|
||||||
|
import { CircularProgress } from '@mui/material';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const MyClassesSkeleton = () => (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MyClassesPage: React.FC = () => {
|
||||||
|
const { profile } = useUser();
|
||||||
|
const { selectedInstituteId } = useNeoInstitute();
|
||||||
|
const {
|
||||||
|
myClasses,
|
||||||
|
classesLoading: myClassesLoading,
|
||||||
|
classesError: myClassesError,
|
||||||
|
fetchMyClasses,
|
||||||
|
} = useTimetableStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyClasses().catch((error) =>
|
||||||
|
logger.warn('my-classes', 'Fetch my classes failed', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [fetchMyClasses, selectedInstituteId]);
|
||||||
|
|
||||||
|
if (myClassesLoading) {
|
||||||
|
return <MyClassesSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleLabel = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'teacher': return { text: 'Teacher', color: 'bg-purple-100 text-purple-700' };
|
||||||
|
case 'student': return { text: 'Student', color: 'bg-green-100 text-green-700' };
|
||||||
|
case 'assistant': return { text: 'Assistant', color: 'bg-blue-100 text-blue-700' };
|
||||||
|
default: return { text: role, color: 'bg-gray-100 text-gray-700' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return { text: 'Active', color: 'bg-green-100 text-green-700' };
|
||||||
|
case 'pending': return { text: 'Pending', color: 'bg-yellow-100 text-yellow-700' };
|
||||||
|
case 'completed': return { text: 'Completed', color: 'bg-gray-100 text-gray-700' };
|
||||||
|
case 'cancelled': return { text: 'Cancelled', color: 'bg-red-100 text-red-700' };
|
||||||
|
default: return { text: status, color: 'bg-gray-100 text-gray-700' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (myClassesError) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
<p>Error loading your classes: {myClassesError}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchMyClasses()}
|
||||||
|
className="mt-2 text-sm font-medium text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate classes by role
|
||||||
|
const teachingClasses = myClasses.filter(c => c.role === 'teacher' || c.role === 'assistant');
|
||||||
|
const enrolledClasses = myClasses.filter(c => c.role === 'student');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">My Classes</h1>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
Manage your enrolled classes and teaching assignments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teaching Section */}
|
||||||
|
{teachingClasses.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<School className="text-purple-600" size={24} />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Teaching</h2>
|
||||||
|
<span className="px-2 py-1 bg-purple-100 text-purple-700 text-sm font-medium rounded-full">
|
||||||
|
{teachingClasses.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{teachingClasses.map((classItem) => (
|
||||||
|
<Link
|
||||||
|
key={classItem.id}
|
||||||
|
to={`/classes/${classItem.id}`}
|
||||||
|
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||||
|
{classItem.name}
|
||||||
|
</h3>
|
||||||
|
{classItem.class_code && (
|
||||||
|
<p className="text-sm text-gray-500">{classItem.class_code}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||||
|
{classItem.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded ${getRoleLabel(classItem.role).color}`}>
|
||||||
|
{getRoleLabel(classItem.role).text}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded ${getStatusLabel(classItem.status).color}`}>
|
||||||
|
{getStatusLabel(classItem.status).text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<People size={14} />
|
||||||
|
<span>{(classItem as any).enrolled_count || 0} students</span>
|
||||||
|
</div>
|
||||||
|
{classItem.academic_year && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<School size={14} />
|
||||||
|
<span>{classItem.academic_year}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enrolled Section */}
|
||||||
|
{enrolledClasses.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<MenuBook className="text-green-600" size={24} />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Enrolled</h2>
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
|
||||||
|
{enrolledClasses.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{enrolledClasses.map((classItem) => (
|
||||||
|
<Link
|
||||||
|
key={classItem.id}
|
||||||
|
to={`/classes/${classItem.id}`}
|
||||||
|
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||||
|
{classItem.name}
|
||||||
|
</h3>
|
||||||
|
{classItem.class_code && (
|
||||||
|
<p className="text-sm text-gray-500">{classItem.class_code}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||||
|
{classItem.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded ${getStatusLabel(classItem.status).color}`}>
|
||||||
|
{getStatusLabel(classItem.status).text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<School size={14} />
|
||||||
|
<span>
|
||||||
|
{(classItem as any).primary_teacher_name} {}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{classItem.academic_year && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<School size={14} />
|
||||||
|
<span>{classItem.academic_year}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{myClasses.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<MenuBook className="mx-auto h-12 w-12 text-gray-300" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-gray-900">No classes found</h3>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
You are not enrolled in or teaching any classes yet.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/classes"
|
||||||
|
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
|
||||||
|
>
|
||||||
|
Browse Available Classes
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MyClassesPage;
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||||
|
Table, TableHead, TableBody, TableRow, TableCell,
|
||||||
|
Select, MenuItem, FormControl, Tooltip, Divider,
|
||||||
|
Accordion, AccordionSummary, AccordionDetails, Grid,
|
||||||
|
Card, CardContent, IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ExpandMore, People, School, MenuBook, EventNote,
|
||||||
|
HourglassEmpty, Refresh, Edit,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
const DAY_TYPE_OPTIONS = ['Academic', 'Holiday', 'Staff', 'OffTimetable'];
|
||||||
|
const DAY_TYPE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning' | 'error'> = {
|
||||||
|
Academic: 'primary',
|
||||||
|
Holiday: 'warning',
|
||||||
|
Staff: 'success',
|
||||||
|
OffTimetable: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Term {
|
||||||
|
id: string;
|
||||||
|
term_name: string;
|
||||||
|
term_number: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
academic_days: number;
|
||||||
|
total_days: number;
|
||||||
|
is_current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarDay {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
day_of_week: string;
|
||||||
|
day_type: string;
|
||||||
|
week_cycle: string;
|
||||||
|
week_number: number | null;
|
||||||
|
academic_day_number: number | null;
|
||||||
|
excluded_period_codes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Overview {
|
||||||
|
user_role: string;
|
||||||
|
counts: {
|
||||||
|
staff: number;
|
||||||
|
students: number;
|
||||||
|
classes: number;
|
||||||
|
pending_invitations: number;
|
||||||
|
};
|
||||||
|
terms: Term[];
|
||||||
|
has_calendar: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stat card ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatCard({ label, value, icon: Icon, onClick }: {
|
||||||
|
label: string; value: number | string; icon: React.ElementType; onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{ cursor: onClick ? 'pointer' : 'default', '&:hover': onClick ? { bgcolor: 'action.hover' } : {} }}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||||
|
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Calendar days table ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CalendarDaysTable({ termId, headers, isAdmin }: {
|
||||||
|
termId: string; headers: Record<string, string>; isAdmin: boolean;
|
||||||
|
}) {
|
||||||
|
const [days, setDays] = useState<CalendarDay[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`${API_BASE}/school/calendar/days?term_id=${termId}`, { headers })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setDays(data.days || []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [termId]);
|
||||||
|
|
||||||
|
const handleTypeChange = async (dayId: string, newType: string) => {
|
||||||
|
setSaving(dayId);
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/school/calendar/days/${dayId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ day_type: newType }),
|
||||||
|
});
|
||||||
|
setDays(prev => prev.map(d => d.id === dayId ? { ...d, day_type: newType } : d));
|
||||||
|
} catch { }
|
||||||
|
setSaving(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}><CircularProgress size={20} /></Box>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small" sx={{ minWidth: 500 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Date</TableCell>
|
||||||
|
<TableCell>Day</TableCell>
|
||||||
|
<TableCell>Week</TableCell>
|
||||||
|
<TableCell>Cycle</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{days.map(day => (
|
||||||
|
<TableRow key={day.id} sx={{ opacity: day.day_type !== 'Academic' ? 0.7 : 1 }}>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.78rem' }}>{day.date}</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: '0.78rem' }}>{day.day_of_week.slice(0, 3)}</TableCell>
|
||||||
|
<TableCell sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{day.week_number ?? '—'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{day.week_cycle
|
||||||
|
? <Chip label={`W${day.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||||||
|
: <Typography variant="caption" sx={{ color: 'text.disabled' }}>—</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isAdmin ? (
|
||||||
|
<FormControl size="small" sx={{ minWidth: 110 }}>
|
||||||
|
<Select
|
||||||
|
value={day.day_type}
|
||||||
|
onChange={e => handleTypeChange(day.id, e.target.value)}
|
||||||
|
disabled={saving === day.id}
|
||||||
|
sx={{ fontSize: '0.75rem', height: 24 }}
|
||||||
|
>
|
||||||
|
{DAY_TYPE_OPTIONS.map(opt => (
|
||||||
|
<MenuItem key={opt} value={opt} sx={{ fontSize: '0.78rem' }}>{opt}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
label={day.day_type}
|
||||||
|
size="small"
|
||||||
|
color={DAY_TYPE_COLORS[day.day_type] ?? 'default'}
|
||||||
|
sx={{ fontSize: '0.65rem', height: 18 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SchoolSettingsPage: React.FC = () => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [overview, setOverview] = useState<Overview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedTerm, setExpandedTerm] = useState<string | false>(false);
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||||
|
|
||||||
|
const loadOverview = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/school/overview`, { headers });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') setOverview(data);
|
||||||
|
else setError(data.detail || 'Failed to load school overview');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => { loadOverview(); }, [loadOverview]);
|
||||||
|
|
||||||
|
const isAdmin = overview?.user_role === 'school_admin' || overview?.user_role === 'department_head';
|
||||||
|
|
||||||
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}><CircularProgress /></Box>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 960, mx: 'auto' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>School Settings</Typography>
|
||||||
|
{overview && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{overview.user_role}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="Refresh">
|
||||||
|
<IconButton size="small" onClick={loadOverview}><Refresh fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
|
||||||
|
{overview && (
|
||||||
|
<>
|
||||||
|
{/* Stat cards */}
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<StatCard label="Staff" value={overview.counts.staff} icon={People} onClick={() => navigate('/staff-manager')} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<StatCard label="Students" value={overview.counts.students} icon={School} onClick={() => navigate('/student-manager')} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<StatCard label="Classes" value={overview.counts.classes} icon={MenuBook} onClick={() => navigate('/classes')} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<StatCard label="Pending invites" value={overview.counts.pending_invitations} icon={HourglassEmpty} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>Academic Calendar</Typography>
|
||||||
|
{!overview.has_calendar && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
No calendar configured
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{overview.terms.length === 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
No academic calendar set up yet. Use the School Calendar Wizard in the navigation panel to set one up.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
overview.terms.map(term => (
|
||||||
|
<Accordion
|
||||||
|
key={term.id}
|
||||||
|
expanded={expandedTerm === term.id}
|
||||||
|
onChange={(_, open) => setExpandedTerm(open ? term.id : false)}
|
||||||
|
sx={{ mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%', pr: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 80 }}>
|
||||||
|
{term.term_name}
|
||||||
|
</Typography>
|
||||||
|
{term.is_current && (
|
||||||
|
<Chip label="Current" size="small" color="primary" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{term.start_date} → {term.end_date}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||||
|
<Chip label={`${term.academic_days} teaching days`} size="small" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||||
|
{term.total_days > term.academic_days && (
|
||||||
|
<Chip
|
||||||
|
label={`${term.total_days - term.academic_days} non-teaching`}
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
sx={{ fontSize: '0.65rem', height: 18 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ p: 0 }}>
|
||||||
|
<CalendarDaysTable termId={term.id} headers={headers} isAdmin={isAdmin} />
|
||||||
|
{isAdmin && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', px: 2, py: 1, color: 'text.secondary' }}>
|
||||||
|
Changing a day type automatically creates or removes its periods.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
{/* Quick links */}
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Manage</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<Button variant="outlined" size="small" startIcon={<People />} onClick={() => navigate('/staff-manager')}>
|
||||||
|
Staff Manager
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" size="small" startIcon={<School />} onClick={() => navigate('/student-manager')}>
|
||||||
|
Student Manager
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" size="small" startIcon={<MenuBook />} onClick={() => navigate('/classes')}>
|
||||||
|
Classes
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" size="small" startIcon={<EventNote />} onClick={() => navigate('/my-lessons')}>
|
||||||
|
My Lessons
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SchoolSettingsPage;
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||||
|
TextField, Select, MenuItem, FormControl, InputLabel,
|
||||||
|
Table, TableHead, TableBody, TableRow, TableCell,
|
||||||
|
IconButton, Tooltip, Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
PersonAdd, Cancel, Send, Refresh,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StaffMember {
|
||||||
|
profile_id: string;
|
||||||
|
email: string | null;
|
||||||
|
username: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
role: string;
|
||||||
|
joined_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invitation {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning'> = {
|
||||||
|
school_admin: 'primary',
|
||||||
|
department_head: 'warning',
|
||||||
|
teacher: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
||||||
|
pending: 'warning',
|
||||||
|
accepted: 'success',
|
||||||
|
expired: 'error',
|
||||||
|
cancelled: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const StaffManagerPage: React.FC = () => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [staff, setStaff] = useState<StaffMember[]>([]);
|
||||||
|
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Invite form
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
|
const [inviteRole, setInviteRole] = useState('teacher');
|
||||||
|
const [inviting, setInviting] = useState(false);
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [staffRes, invRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/users/staff`, { headers }),
|
||||||
|
fetch(`${API_BASE}/users/invitations?role=teacher&status=pending`, { headers })
|
||||||
|
.then(r => r.json())
|
||||||
|
.catch(() => ({ invitations: [] })),
|
||||||
|
].map((p, i) => i === 0 ? (p as Promise<Response>).then(r => r.json()) : p));
|
||||||
|
if (staffRes.status === 'ok') setStaff(staffRes.staff || []);
|
||||||
|
else setError(staffRes.detail || 'Failed to load staff');
|
||||||
|
setInvitations(invRes.invitations || []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
const handleInvite = async () => {
|
||||||
|
if (!inviteEmail.trim() || !accessToken) return;
|
||||||
|
setInviting(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: inviteEmail.trim().toLowerCase(), role: inviteRole }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok' || data.status === 'already_pending') {
|
||||||
|
setSuccessMsg(
|
||||||
|
data.status === 'already_pending'
|
||||||
|
? `Invitation already pending for ${inviteEmail}`
|
||||||
|
: `Invitation sent to ${inviteEmail}`
|
||||||
|
);
|
||||||
|
setInviteEmail('');
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
setError(data.detail || data.message || 'Invite failed');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setInviting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (id: string, email: string) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
|
||||||
|
setSuccessMsg(`Cancelled invitation for ${email}`);
|
||||||
|
loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResend = async (id: string, email: string) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/invitations/${id}/resend`, { method: 'POST', headers });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
setSuccessMsg(`Re-sent invitation to ${email}`);
|
||||||
|
} else {
|
||||||
|
setError(data.detail || 'Resend failed');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Staff Manager</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
Invite teachers, department heads, and school admins
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
{successMsg && <Alert severity="success" sx={{ mt: 2 }} onClose={() => setSuccessMsg(null)}>{successMsg}</Alert>}
|
||||||
|
|
||||||
|
{/* Invite form */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 3, alignItems: 'flex-end' }}>
|
||||||
|
<TextField
|
||||||
|
label="Email address"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={e => setInviteEmail(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleInvite()}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
disabled={inviting}
|
||||||
|
/>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Role</InputLabel>
|
||||||
|
<Select label="Role" value={inviteRole} onChange={e => setInviteRole(e.target.value)} disabled={inviting}>
|
||||||
|
<MenuItem value="teacher">Teacher</MenuItem>
|
||||||
|
<MenuItem value="department_head">Department Head</MenuItem>
|
||||||
|
<MenuItem value="school_admin">School Admin</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
|
||||||
|
onClick={handleInvite}
|
||||||
|
disabled={inviting || !inviteEmail.trim()}
|
||||||
|
sx={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Send Invite
|
||||||
|
</Button>
|
||||||
|
<Tooltip title="Refresh">
|
||||||
|
<IconButton size="small" onClick={loadData} disabled={loading}>
|
||||||
|
<Refresh fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Pending invitations */}
|
||||||
|
{invitations.length > 0 && (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Pending Invitations ({invitations.length})</Typography>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Email</TableCell>
|
||||||
|
<TableCell>Role</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Sent</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{invitations.map(inv => (
|
||||||
|
<TableRow key={inv.id}>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{inv.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={inv.role} size="small" color={ROLE_COLORS[inv.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={inv.status} size="small" color={STATUS_COLORS[inv.status] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||||
|
{new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Resend">
|
||||||
|
<IconButton size="small" onClick={() => handleResend(inv.id, inv.email)}>
|
||||||
|
<Send fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Cancel">
|
||||||
|
<IconButton size="small" onClick={() => handleCancel(inv.id, inv.email)}>
|
||||||
|
<Cancel fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
{/* Active staff */}
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
Active Staff ({loading ? '…' : staff.length})
|
||||||
|
</Typography>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||||
|
) : staff.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No staff members yet.</Typography>
|
||||||
|
) : (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Email</TableCell>
|
||||||
|
<TableCell>Role</TableCell>
|
||||||
|
<TableCell>Joined</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{staff.map(s => (
|
||||||
|
<TableRow key={s.profile_id}>
|
||||||
|
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>
|
||||||
|
{s.display_name || s.username || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'text.secondary' }}>
|
||||||
|
{s.email || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={s.role} size="small" color={ROLE_COLORS[s.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||||
|
{new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StaffManagerPage;
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, CircularProgress, Alert, Chip, IconButton, Tooltip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ChevronLeft, ChevronRight, Today } from '@mui/icons-material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Lesson {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
period_code: string;
|
||||||
|
period_name: string;
|
||||||
|
start_time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
|
class_name: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
year_group: string | null;
|
||||||
|
week_cycle: string;
|
||||||
|
day_of_week: string;
|
||||||
|
status: string;
|
||||||
|
teacher_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayEntry {
|
||||||
|
date: string;
|
||||||
|
day_of_week: string;
|
||||||
|
is_today: boolean;
|
||||||
|
lessons: Lesson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toMonday(d: Date): string {
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = day === 0 ? -6 : 1 - day;
|
||||||
|
const m = new Date(d);
|
||||||
|
m.setDate(d.getDate() + diff);
|
||||||
|
return m.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWeeks(iso: string, n: number): string {
|
||||||
|
const d = new Date(iso + 'T00:00:00');
|
||||||
|
d.setDate(d.getDate() + n * 7);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string): string {
|
||||||
|
return new Date(iso + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
||||||
|
planned: 'default',
|
||||||
|
in_progress: 'primary',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'error',
|
||||||
|
substituted: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Lesson card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LessonCard({ lesson }: { lesson: Lesson }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.25, mb: 0.75,
|
||||||
|
border: '1px solid', borderColor: 'divider', borderRadius: 1,
|
||||||
|
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
|
||||||
|
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||||
|
{lesson.class_name || lesson.period_code}
|
||||||
|
</Typography>
|
||||||
|
{lesson.subject && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||||
|
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||||
|
{lesson.start_time ? `${lesson.start_time}–${lesson.end_time}` : lesson.period_name}
|
||||||
|
</Typography>
|
||||||
|
{lesson.week_cycle && (
|
||||||
|
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
label={lesson.status}
|
||||||
|
size="small"
|
||||||
|
color={STATUS_COLORS[lesson.status] ?? 'default'}
|
||||||
|
sx={{ height: 16, fontSize: '0.65rem' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{lesson.teacher_name && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem' }}>
|
||||||
|
{lesson.teacher_name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const StudentLessonsPage: React.FC = () => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
|
||||||
|
const [days, setDays] = useState<DayEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (ws: string) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/timetable/student/lessons?week_start=${ws}&weeks=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') setDays(data.days);
|
||||||
|
else setError(data.message || 'Failed to load lessons');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => { load(weekStart); }, [weekStart, load]);
|
||||||
|
|
||||||
|
const totalLessons = days.reduce((s, d) => s + d.lessons.length, 0);
|
||||||
|
const weekLabel = days.length > 0
|
||||||
|
? `${fmtDate(days[0].date)} – ${fmtDate(days[days.length - 1].date)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={700}>My Lessons</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
<Tooltip title="This week">
|
||||||
|
<IconButton size="small" onClick={() => setWeekStart(toMonday(new Date()))}>
|
||||||
|
<Today fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, -1))}>
|
||||||
|
<ChevronLeft />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, 1))}>
|
||||||
|
<ChevronRight />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
|
||||||
|
{days.map(day => (
|
||||||
|
<Box key={day.date}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1, mb: 0.75, borderRadius: 1, textAlign: 'center',
|
||||||
|
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
|
||||||
|
color: day.is_today ? 'primary.contrastText' : 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
|
||||||
|
{day.day_of_week.slice(0, 3)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
|
||||||
|
{fmtDate(day.date)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{day.lessons.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
|
||||||
|
No lessons
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
day.lessons.map(lesson => (
|
||||||
|
<LessonCard key={lesson.id} lesson={lesson} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentLessonsPage;
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||||
|
TextField, Table, TableHead, TableBody, TableRow, TableCell,
|
||||||
|
IconButton, Tooltip, Divider, Tabs, Tab,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
PersonAdd, Cancel, Send, Refresh, UploadFile,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
interface Student {
|
||||||
|
profile_id: string;
|
||||||
|
email: string | null;
|
||||||
|
username: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
role: string;
|
||||||
|
joined_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invitation {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'warning' | 'success' | 'error'> = {
|
||||||
|
pending: 'warning',
|
||||||
|
accepted: 'success',
|
||||||
|
expired: 'error',
|
||||||
|
cancelled: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const StudentManagerPage: React.FC = () => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
const [students, setStudents] = useState<Student[]>([]);
|
||||||
|
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Single invite
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
|
const [inviting, setInviting] = useState(false);
|
||||||
|
|
||||||
|
// CSV import
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [csvEmails, setCsvEmails] = useState<string[]>([]);
|
||||||
|
const [csvPreview, setCsvPreview] = useState(false);
|
||||||
|
const [csvProgress, setCsvProgress] = useState(0);
|
||||||
|
const [csvTotal, setCsvTotal] = useState(0);
|
||||||
|
const [bulkRunning, setBulkRunning] = useState(false);
|
||||||
|
|
||||||
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [studRes, invRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/users/students`, { headers }).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE}/users/invitations?role=student`, { headers }).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
if (studRes.status === 'ok') setStudents(studRes.students || []);
|
||||||
|
else setError(studRes.detail || 'Failed to load students');
|
||||||
|
setInvitations(invRes.invitations || []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
const sendInvite = async (email: string): Promise<'ok' | 'already_pending' | 'error'> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.trim().toLowerCase(), role: 'student' }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') return 'ok';
|
||||||
|
if (data.status === 'already_pending') return 'already_pending';
|
||||||
|
return 'error';
|
||||||
|
} catch {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInvite = async () => {
|
||||||
|
if (!inviteEmail.trim() || !accessToken) return;
|
||||||
|
setInviting(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
const result = await sendInvite(inviteEmail);
|
||||||
|
if (result === 'ok') {
|
||||||
|
setSuccessMsg(`Invitation sent to ${inviteEmail}`);
|
||||||
|
setInviteEmail('');
|
||||||
|
loadData();
|
||||||
|
} else if (result === 'already_pending') {
|
||||||
|
setSuccessMsg(`Invitation already pending for ${inviteEmail}`);
|
||||||
|
setInviteEmail('');
|
||||||
|
} else {
|
||||||
|
setError(`Failed to invite ${inviteEmail}`);
|
||||||
|
}
|
||||||
|
setInviting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = ev => {
|
||||||
|
const text = ev.target?.result as string;
|
||||||
|
const emails = text
|
||||||
|
.split(/[\n,;]+/)
|
||||||
|
.map(s => s.trim().toLowerCase())
|
||||||
|
.filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
|
||||||
|
setCsvEmails([...new Set(emails)]);
|
||||||
|
setCsvPreview(true);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
// Reset file input so same file can be re-selected
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkInvite = async () => {
|
||||||
|
if (csvEmails.length === 0) return;
|
||||||
|
setBulkRunning(true);
|
||||||
|
setCsvTotal(csvEmails.length);
|
||||||
|
setCsvProgress(0);
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const email of csvEmails) {
|
||||||
|
const result = await sendInvite(email);
|
||||||
|
if (result === 'ok' || result === 'already_pending') sent++;
|
||||||
|
else failed++;
|
||||||
|
setCsvProgress(prev => prev + 1);
|
||||||
|
}
|
||||||
|
setBulkRunning(false);
|
||||||
|
setCsvPreview(false);
|
||||||
|
setCsvEmails([]);
|
||||||
|
setSuccessMsg(`Bulk invite: ${sent} sent${failed > 0 ? `, ${failed} failed` : ''}`);
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (id: string, email: string) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
|
||||||
|
setSuccessMsg(`Cancelled invitation for ${email}`);
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResend = async (id: string, email: string) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
const res = await fetch(`${API_BASE}/users/invitations/${id}/resend`, { method: 'POST', headers });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') setSuccessMsg(`Re-sent invitation to ${email}`);
|
||||||
|
else setError(data.detail || 'Resend failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingInvitations = invitations.filter(i => i.status === 'pending');
|
||||||
|
const otherInvitations = invitations.filter(i => i.status !== 'pending');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Student Manager</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
Invite students individually or via CSV import
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
{successMsg && <Alert severity="success" sx={{ mt: 2 }} onClose={() => setSuccessMsg(null)}>{successMsg}</Alert>}
|
||||||
|
|
||||||
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mt: 2, mb: 2 }}>
|
||||||
|
<Tab label="Invite" />
|
||||||
|
<Tab label={`Invitations (${invitations.length})`} />
|
||||||
|
<Tab label={`Students (${students.length})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* ── Tab 0: Invite ─────────────────────────────────────────── */}
|
||||||
|
{tab === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Single invite</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end', mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
label="Email address"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={e => setInviteEmail(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleInvite()}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
disabled={inviting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
|
||||||
|
onClick={handleInvite}
|
||||||
|
disabled={inviting || !inviteEmail.trim()}
|
||||||
|
>
|
||||||
|
Send Invite
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Bulk CSV import</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 1.5 }}>
|
||||||
|
Upload a .csv or .txt file with one email per line (or comma/semicolon separated). Duplicate and invalid addresses are filtered automatically.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.txt"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleCsvFile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFile />}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={bulkRunning}
|
||||||
|
>
|
||||||
|
Choose File
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{csvPreview && csvEmails.length > 0 && (
|
||||||
|
<Box sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
{csvEmails.length} valid email{csvEmails.length !== 1 ? 's' : ''} found
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ maxHeight: 120, overflowY: 'auto', mt: 0.5 }}>
|
||||||
|
{csvEmails.slice(0, 20).map(e => (
|
||||||
|
<Typography key={e} variant="caption" sx={{ display: 'block', fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
|
||||||
|
{e}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
{csvEmails.length > 20 && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
… and {csvEmails.length - 20} more
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleBulkInvite}
|
||||||
|
disabled={bulkRunning}
|
||||||
|
startIcon={bulkRunning ? <CircularProgress size={14} /> : <Send />}
|
||||||
|
>
|
||||||
|
{bulkRunning ? `Sending… ${csvProgress}/${csvTotal}` : `Send ${csvEmails.length} Invites`}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => { setCsvEmails([]); setCsvPreview(false); }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tab 1: Invitations ────────────────────────────────────── */}
|
||||||
|
{tab === 1 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2">All Invitations</Typography>
|
||||||
|
<Tooltip title="Refresh">
|
||||||
|
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||||
|
) : invitations.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No invitations yet.</Typography>
|
||||||
|
) : (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Email</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Sent</TableCell>
|
||||||
|
<TableCell>Expires</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{invitations.map(inv => (
|
||||||
|
<TableRow key={inv.id}>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{inv.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={inv.status} size="small" color={STATUS_COLORS[inv.status] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||||
|
{new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||||
|
{new Date(inv.expires_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{inv.status === 'pending' && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Resend">
|
||||||
|
<IconButton size="small" onClick={() => handleResend(inv.id, inv.email)}>
|
||||||
|
<Send fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Cancel">
|
||||||
|
<IconButton size="small" onClick={() => handleCancel(inv.id, inv.email)}>
|
||||||
|
<Cancel fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tab 2: Students ───────────────────────────────────────── */}
|
||||||
|
{tab === 2 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2">Enrolled Students ({students.length})</Typography>
|
||||||
|
<Tooltip title="Refresh">
|
||||||
|
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||||
|
) : students.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No enrolled students yet.</Typography>
|
||||||
|
) : (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Email</TableCell>
|
||||||
|
<TableCell>Joined</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{students.map(s => (
|
||||||
|
<TableRow key={s.profile_id}>
|
||||||
|
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>
|
||||||
|
{s.display_name || s.username || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'text.secondary' }}>
|
||||||
|
{s.email || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||||
|
{new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentManagerPage;
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
TextField, Select, MenuItem, FormControl, InputLabel,
|
||||||
|
Divider, IconButton, Tooltip, List, ListItemButton, ListItemText,
|
||||||
|
InputAdornment,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ChevronLeft, ChevronRight, Today, EditNote, LibraryAdd, Search, OpenInNew,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Lesson {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
period_code: string;
|
||||||
|
period_name: string;
|
||||||
|
start_time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
|
class_name: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
year_group: string | null;
|
||||||
|
week_cycle: string;
|
||||||
|
day_of_week: string;
|
||||||
|
status: string;
|
||||||
|
lesson_plan: Record<string, any>;
|
||||||
|
notes: string | null;
|
||||||
|
whiteboard_room_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayEntry {
|
||||||
|
date: string;
|
||||||
|
day_of_week: string;
|
||||||
|
is_today: boolean;
|
||||||
|
lessons: Lesson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toMonday(d: Date): string {
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = day === 0 ? -6 : 1 - day;
|
||||||
|
const monday = new Date(d);
|
||||||
|
monday.setDate(d.getDate() + diff);
|
||||||
|
return monday.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWeeks(isoDate: string, n: number): string {
|
||||||
|
const d = new Date(isoDate + 'T00:00:00');
|
||||||
|
d.setDate(d.getDate() + n * 7);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(isoDate: string): string {
|
||||||
|
return new Date(isoDate + 'T00:00:00').toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric', month: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
||||||
|
planned: 'default',
|
||||||
|
in_progress: 'primary',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'error',
|
||||||
|
substituted: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Lesson edit dialog ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EditDialogProps {
|
||||||
|
lesson: Lesson | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (id: string, updates: { notes?: string; status?: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [status, setStatus] = useState('planned');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lesson) {
|
||||||
|
setNotes(lesson.notes || '');
|
||||||
|
setStatus(lesson.status || 'planned');
|
||||||
|
}
|
||||||
|
}, [lesson]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!lesson) return;
|
||||||
|
setSaving(true);
|
||||||
|
await onSave(lesson.id, { notes, status });
|
||||||
|
setSaving(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!lesson) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!lesson} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">
|
||||||
|
{lesson.class_name || lesson.period_code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{lesson.date} · {lesson.period_name}
|
||||||
|
{lesson.start_time && ` · ${lesson.start_time}–${lesson.end_time}`}
|
||||||
|
{lesson.week_cycle && ` · Week ${lesson.week_cycle}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||||
|
<FormControl size="small" fullWidth>
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select label="Status" value={status} onChange={e => setStatus(e.target.value)}>
|
||||||
|
<MenuItem value="planned">Planned</MenuItem>
|
||||||
|
<MenuItem value="in_progress">In Progress</MenuItem>
|
||||||
|
<MenuItem value="completed">Completed</MenuItem>
|
||||||
|
<MenuItem value="cancelled">Cancelled</MenuItem>
|
||||||
|
<MenuItem value="substituted">Substituted</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Lesson notes, objectives, reminders…"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} variant="contained" disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={18} /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lesson card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface LessonCardProps {
|
||||||
|
lesson: Lesson;
|
||||||
|
onEdit: (l: Lesson) => void;
|
||||||
|
onAssignPlan: (l: Lesson) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.25,
|
||||||
|
mb: 0.75,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
|
||||||
|
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||||
|
{lesson.class_name || lesson.period_code}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0 }}>
|
||||||
|
<Tooltip title="Assign lesson plan">
|
||||||
|
<IconButton size="small" onClick={() => onAssignPlan(lesson)} sx={{ p: 0.25 }}>
|
||||||
|
<LibraryAdd fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Edit lesson">
|
||||||
|
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ p: 0.25 }}>
|
||||||
|
<EditNote fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{lesson.subject && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||||
|
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||||
|
{lesson.start_time ? `${lesson.start_time}–${lesson.end_time}` : lesson.period_name}
|
||||||
|
</Typography>
|
||||||
|
{lesson.week_cycle && (
|
||||||
|
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
label={lesson.status}
|
||||||
|
size="small"
|
||||||
|
color={STATUS_COLORS[lesson.status] ?? 'default'}
|
||||||
|
sx={{ height: 16, fontSize: '0.65rem' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{lesson.notes && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem', fontStyle: 'italic', mt: 0.25 }}>
|
||||||
|
{lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TaughtLessonsPage: React.FC = () => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
|
||||||
|
const [days, setDays] = useState<DayEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [materializing, setMaterializing] = useState(false);
|
||||||
|
const [materializeResult, setMaterializeResult] = useState<string | null>(null);
|
||||||
|
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
|
||||||
|
const [assigningLesson, setAssigningLesson] = useState<Lesson | null>(null);
|
||||||
|
const [plans, setPlans] = useState<{ id: string; title: string; subject: string | null }[]>([]);
|
||||||
|
const [planSearch, setPlanSearch] = useState('');
|
||||||
|
const [assignSaving, setAssignSaving] = useState(false);
|
||||||
|
const [assignResult, setAssignResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadLessons = useCallback(async (ws: string) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/timetable/lessons?week_start=${ws}&weeks=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
setDays(data.days);
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Failed to load lessons');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => { loadLessons(weekStart); }, [weekStart, loadLessons]);
|
||||||
|
|
||||||
|
const goThisWeek = () => setWeekStart(toMonday(new Date()));
|
||||||
|
const prevWeek = () => setWeekStart(ws => addWeeks(ws, -1));
|
||||||
|
const nextWeek = () => setWeekStart(ws => addWeeks(ws, 1));
|
||||||
|
|
||||||
|
const handleMaterialize = async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setMaterializing(true);
|
||||||
|
setMaterializeResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/timetable/materialize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
setMaterializeResult(`Created ${data.lessons_upserted} lessons, ${data.whiteboard_rooms_created} rooms`);
|
||||||
|
loadLessons(weekStart);
|
||||||
|
} else {
|
||||||
|
setMaterializeResult(`Error: ${data.message}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setMaterializeResult(`Error: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setMaterializing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLesson = async (id: string, updates: { notes?: string; status?: string }) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
loadLessons(weekStart);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAssignDialog = async (lesson: Lesson) => {
|
||||||
|
setAssigningLesson(lesson);
|
||||||
|
setPlanSearch('');
|
||||||
|
setAssignResult(null);
|
||||||
|
if (plans.length === 0) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/lessons/plans`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data)) setPlans(data);
|
||||||
|
} catch { /* silently ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignPlan = async (planId: string) => {
|
||||||
|
if (!assigningLesson) return;
|
||||||
|
setAssignSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/lessons/plans/${planId}/deliver`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ taught_lesson_id: assigningLesson.id }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.id) {
|
||||||
|
setAssignResult('Plan linked ✓');
|
||||||
|
setTimeout(() => { setAssigningLesson(null); setAssignResult(null); }, 1200);
|
||||||
|
} else {
|
||||||
|
setAssignResult(data.detail || 'Failed to link plan');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setAssignResult(e.message);
|
||||||
|
} finally {
|
||||||
|
setAssignSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPlans = plans.filter(p =>
|
||||||
|
!planSearch || p.title.toLowerCase().includes(planSearch.toLowerCase())
|
||||||
|
|| (p.subject || '').toLowerCase().includes(planSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalLessons = days.reduce((sum, d) => sum + d.lessons.length, 0);
|
||||||
|
|
||||||
|
const weekLabel = days.length > 0
|
||||||
|
? `${formatDate(days[0].date)} – ${formatDate(days[days.length - 1].date)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>My Lessons</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleMaterialize}
|
||||||
|
disabled={materializing}
|
||||||
|
sx={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{materializing ? <CircularProgress size={14} sx={{ mr: 1 }} /> : null}
|
||||||
|
Generate Lessons
|
||||||
|
</Button>
|
||||||
|
<Tooltip title="Go to this week">
|
||||||
|
<IconButton size="small" onClick={goThisWeek}><Today fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<IconButton size="small" onClick={prevWeek}><ChevronLeft /></IconButton>
|
||||||
|
<IconButton size="small" onClick={nextWeek}><ChevronRight /></IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{materializeResult && (
|
||||||
|
<Alert
|
||||||
|
severity={materializeResult.startsWith('Error') ? 'error' : 'success'}
|
||||||
|
onClose={() => setMaterializeResult(null)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
{materializeResult}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{/* Week grid */}
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
|
||||||
|
{days.map(day => (
|
||||||
|
<Box key={day.date}>
|
||||||
|
{/* Day header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
mb: 0.75,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
|
||||||
|
color: day.is_today ? 'primary.contrastText' : 'text.primary',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
|
||||||
|
{day.day_of_week.slice(0, 3)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
|
||||||
|
{formatDate(day.date)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Lesson cards */}
|
||||||
|
{day.lessons.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
|
||||||
|
No lessons
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
day.lessons.map(lesson => (
|
||||||
|
<LessonCard
|
||||||
|
key={lesson.id}
|
||||||
|
lesson={lesson}
|
||||||
|
onEdit={setEditingLesson}
|
||||||
|
onAssignPlan={openAssignDialog}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LessonEditDialog
|
||||||
|
lesson={editingLesson}
|
||||||
|
onClose={() => setEditingLesson(null)}
|
||||||
|
onSave={handleSaveLesson}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Assign Plan dialog */}
|
||||||
|
<Dialog open={!!assigningLesson} onClose={() => setAssigningLesson(null)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>
|
||||||
|
<Typography variant="h6">Assign Lesson Plan</Typography>
|
||||||
|
{assigningLesson && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{assigningLesson.class_name || assigningLesson.period_code} · {assigningLesson.date}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ pt: 1 }}>
|
||||||
|
{assignResult ? (
|
||||||
|
<Alert severity={assignResult.includes('✓') ? 'success' : 'error'} sx={{ mb: 1 }}>
|
||||||
|
{assignResult}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Search plans…"
|
||||||
|
value={planSearch}
|
||||||
|
onChange={e => setPlanSearch(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment>,
|
||||||
|
}}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
{plans.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||||
|
No lesson plans yet.{' '}
|
||||||
|
<Button size="small" endIcon={<OpenInNew fontSize="small" />}
|
||||||
|
onClick={() => { setAssigningLesson(null); navigate('/lesson-plans'); }}>
|
||||||
|
Create one
|
||||||
|
</Button>
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List dense disablePadding sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||||
|
{filteredPlans.map(p => (
|
||||||
|
<ListItemButton
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleAssignPlan(p.id)}
|
||||||
|
disabled={assignSaving}
|
||||||
|
sx={{ borderRadius: 1, mb: 0.25 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={p.title}
|
||||||
|
secondary={p.subject || undefined}
|
||||||
|
primaryTypographyProps={{ variant: 'body2' }}
|
||||||
|
secondaryTypographyProps={{ variant: 'caption' }}
|
||||||
|
/>
|
||||||
|
{assignSaving && <CircularProgress size={16} sx={{ ml: 1 }} />}
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={() => setAssigningLesson(null)}>Close</Button>
|
||||||
|
<Button size="small" endIcon={<OpenInNew fontSize="small" />}
|
||||||
|
onClick={() => { setAssigningLesson(null); navigate('/lesson-plans'); }}>
|
||||||
|
Manage Plans
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaughtLessonsPage;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CalendarToday, KeyboardArrowRight } from '@mui/icons-material';
|
||||||
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
const TimetableListPage: React.FC = () => {
|
||||||
|
const { timetables, timetablesLoading, timetablesError, fetchTimetables } = useTimetableStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTimetables();
|
||||||
|
}, [fetchTimetables]);
|
||||||
|
|
||||||
|
if (timetablesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat fetch errors (including 404 — endpoint not yet available) as empty state
|
||||||
|
const showEmpty = timetablesError || timetables.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Timetables</h1>
|
||||||
|
<p className="mt-1 text-gray-500">Your scheduled timetables</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<CalendarToday sx={{ fontSize: 48 }} className="mx-auto mb-4 text-gray-300" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">No timetables yet</h3>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
Timetables are created from a class. Go to{' '}
|
||||||
|
<Link to="/classes" className="text-blue-600 hover:underline">Classes</Link>{' '}
|
||||||
|
to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{timetables.map((timetable) => (
|
||||||
|
<Link
|
||||||
|
key={timetable.id}
|
||||||
|
to={`/timetable/${timetable.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<CalendarToday className="text-blue-500 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">{timetable.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{format(parseISO(timetable.start_date), 'MMM d, yyyy')}
|
||||||
|
{timetable.end_date
|
||||||
|
? ` – ${format(parseISO(timetable.end_date), 'MMM d, yyyy')}`
|
||||||
|
: ' (ongoing)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{timetable.recurrence_rule && (
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full flex-shrink-0">
|
||||||
|
Recurring
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<KeyboardArrowRight className="text-gray-400 flex-shrink-0" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimetableListPage;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { AccessTime, Add, ArrowBack, CalendarToday, Delete, Edit, KeyboardArrowLeft, KeyboardArrowRight, LocationOn } from '@mui/icons-material';
|
||||||
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
import { format, parseISO, addDays, startOfWeek, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
|
const TimetablePage: React.FC = () => {
|
||||||
|
const { timetableId } = useParams<{ timetableId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { profile } = useUser();
|
||||||
|
const {
|
||||||
|
currentTimetable,
|
||||||
|
currentLessons,
|
||||||
|
timetableDetailLoading,
|
||||||
|
timetableDetailError,
|
||||||
|
fetchTimetable,
|
||||||
|
deleteTimetable,
|
||||||
|
clearCurrentTimetable,
|
||||||
|
} = useTimetableStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timetableId) {
|
||||||
|
fetchTimetable(timetableId);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
clearCurrentTimetable();
|
||||||
|
};
|
||||||
|
}, [timetableId, fetchTimetable, clearCurrentTimetable]);
|
||||||
|
|
||||||
|
const handleDeleteTimetable = async () => {
|
||||||
|
if (!timetableId) return;
|
||||||
|
if (confirm('Are you sure you want to delete this timetable?')) {
|
||||||
|
await deleteTimetable(timetableId);
|
||||||
|
navigate(`/timetable/classes/${currentTimetable?.class_id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group lessons by day
|
||||||
|
const lessonsByDay = (currentLessons || []).reduce((acc, lesson) => {
|
||||||
|
const date = lesson.day_of_week || format(parseISO(lesson.start_time), 'yyyy-MM-dd');
|
||||||
|
if (!acc[date]) acc[date] = [];
|
||||||
|
acc[date].push(lesson);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, typeof currentLessons>);
|
||||||
|
|
||||||
|
// Sort lessons within each day by start time
|
||||||
|
Object.keys(lessonsByDay).forEach(day => {
|
||||||
|
lessonsByDay[day].sort((a, b) =>
|
||||||
|
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (timetableDetailLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timetableDetailError || !currentTimetable) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-red-800 mb-2">Error Loading Timetable</h2>
|
||||||
|
<p className="text-red-600">{timetableDetailError || 'Timetable not found'}</p>
|
||||||
|
<Link to="/timetable/classes" className="text-blue-600 hover:underline mt-4 inline-block">
|
||||||
|
Back to Classes
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to={`/timetable/classes/${currentTimetable.class_id}`}
|
||||||
|
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||||
|
>
|
||||||
|
<ArrowBack sx={{ fontSize: 18 }} />
|
||||||
|
Back to Class
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">{currentTimetable.name}</h1>
|
||||||
|
<div className="flex items-center gap-4 text-gray-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CalendarToday sx={{ fontSize: 16 }} />
|
||||||
|
{format(parseISO(currentTimetable.effective_from), 'MMM d, yyyy')}
|
||||||
|
{currentTimetable.effective_until && ` - ${format(parseISO(currentTimetable.effective_until), 'MMM d, yyyy')}`}
|
||||||
|
</span>
|
||||||
|
{currentTimetable.is_recurring && (
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full">
|
||||||
|
Recurring
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/timetable/timetables/${timetableId}/lessons/new`}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Add sx={{ fontSize: 18 }} />
|
||||||
|
Add Lesson
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteTimetable}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: 18 }} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lessons List */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
Lessons ({currentLessons.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentLessons.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-gray-500">
|
||||||
|
<AccessTime sx={{ fontSize: 48 }} className="mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="text-lg font-medium mb-2">No lessons scheduled</p>
|
||||||
|
<p>Add lessons to build your timetable</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{Object.entries(lessonsByDay).map(([day, lessons]) => (
|
||||||
|
<div key={day} className="p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-4">
|
||||||
|
{currentTimetable.is_recurring
|
||||||
|
? `Day ${day}`
|
||||||
|
: format(parseISO(day), 'EEEE, MMMM d, yyyy')}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{lessons.map((lesson) => (
|
||||||
|
<Link
|
||||||
|
key={lesson.id}
|
||||||
|
to={`/timetable/lessons/${lesson.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-16 text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{format(parseISO(lesson.start_time), 'HH:mm')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{format(parseISO(lesson.end_time), 'HH:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-gray-900 truncate">
|
||||||
|
{lesson.title}
|
||||||
|
</h4>
|
||||||
|
{lesson.description && (
|
||||||
|
<p className="text-sm text-gray-500 truncate">
|
||||||
|
{lesson.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{lesson.location && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<LocationOn sx={{ fontSize: 14 }} />
|
||||||
|
{lesson.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lesson.room && (
|
||||||
|
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded">
|
||||||
|
Room {lesson.room}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimetablePage;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Timetable Module - Page Exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as TimetablePage } from './TimetablePage';
|
||||||
|
export { default as TimetableListPage } from './TimetableListPage';
|
||||||
|
export { default as ClassesPage } from './ClassesPage';
|
||||||
|
export { default as LessonPage } from './LessonPage';
|
||||||
|
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
|
||||||
|
export { default as MyClassesPage } from './MyClassesPage';
|
||||||
|
export { default as EnrollmentRequestsPage } from './EnrollmentRequestsPage';
|
||||||
|
export { default as StaffManagerPage } from './StaffManagerPage';
|
||||||
|
export { default as StudentManagerPage } from './StudentManagerPage';
|
||||||
|
export { default as SchoolSettingsPage } from './SchoolSettingsPage';
|
||||||
|
export { default as ClassDetailPage } from './ClassDetailPage';
|
||||||
|
export { default as StudentLessonsPage } from './StudentLessonsPage';
|
||||||
|
|
||||||
|
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';
|
||||||
|
export { default as LessonPlansPage } from './LessonPlansPage';
|
||||||
|
export { default as LessonPlanDetailPage } from './LessonPlanDetailPage';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||||
import { SelectChangeEvent } from '@mui/material/Select';
|
import { SelectChangeEvent } from '@mui/material/Select';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type Manifest = {
|
type Manifest = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
@@ -41,6 +41,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
combinedBundles?: Array<{ id: string }>;
|
combinedBundles?: Array<{ id: string }>;
|
||||||
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
|
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [manifest, setManifest] = useState<Manifest | null>(null);
|
const [manifest, setManifest] = useState<Manifest | null>(null);
|
||||||
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
|
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
|
||||||
const [mode, setMode] = useState<Mode>('markdown_full');
|
const [mode, setMode] = useState<Mode>('markdown_full');
|
||||||
@@ -49,13 +50,13 @@ export const CCBundleViewer: React.FC<{
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
|
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
|
||||||
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
|
const API_BASE_FALLBACK = '/api';
|
||||||
|
|
||||||
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
|
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`;
|
return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`;
|
||||||
}, [API_BASE]);
|
}, [API_BASE, accessToken]);
|
||||||
|
|
||||||
const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
|
const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
|
||||||
if (!s || typeof s !== 'string') return s || '';
|
if (!s || typeof s !== 'string') return s || '';
|
||||||
@@ -222,7 +223,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
setManifest(null);
|
setManifest(null);
|
||||||
if (combinedBundles && combinedBundles.length > 0) {
|
if (combinedBundles && combinedBundles.length > 0) {
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const ms: Manifest[] = [];
|
const ms: Manifest[] = [];
|
||||||
for (const b of combinedBundles) {
|
for (const b of combinedBundles) {
|
||||||
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
@@ -239,7 +240,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
}
|
}
|
||||||
if (!bundleId) return;
|
if (!bundleId) return;
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const rawManifest: Manifest = await res.json();
|
const rawManifest: Manifest = await res.json();
|
||||||
@@ -266,7 +267,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
let textParts: string[] = [];
|
let textParts: string[] = [];
|
||||||
let jsonParts: string[] = [];
|
let jsonParts: string[] = [];
|
||||||
for (const m of combinedManifests) {
|
for (const m of combinedManifests) {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
let rel: string | undefined;
|
let rel: string | undefined;
|
||||||
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
|
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
|
||||||
else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full;
|
else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full;
|
||||||
@@ -348,7 +349,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
relPath = rec?.path;
|
relPath = rec?.path;
|
||||||
}
|
}
|
||||||
if (!relPath) { setContent(''); setLoading(false); return; }
|
if (!relPath) { setContent(''); setLoading(false); return; }
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const url = await proxyUrl(bucket, relPath);
|
const url = await proxyUrl(bucket, relPath);
|
||||||
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Box, CircularProgress, IconButton } from '@mui/material';
|
import { Box, CircularProgress, IconButton } from '@mui/material';
|
||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
|
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
sectionRange?: { start: number; end: number };
|
sectionRange?: { start: number; end: number };
|
||||||
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
|
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
|
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
|
||||||
@@ -181,10 +182,10 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Try page-images manifest first
|
// Try page-images manifest first
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||||
try {
|
try {
|
||||||
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (mRes.ok) {
|
if (mRes.ok) {
|
||||||
const m: PageImagesManifest = await mRes.json();
|
const m: PageImagesManifest = await mRes.json();
|
||||||
@@ -198,8 +199,8 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: Load artefacts for file to find docling JSON artefacts
|
// Legacy: Load artefacts for file to find docling JSON artefacts
|
||||||
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || '/api'}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
|
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
|
||||||
const artefacts: Artefact[] = await artefactsRes.json();
|
const artefacts: Artefact[] = await artefactsRes.json();
|
||||||
@@ -216,7 +217,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
|
|
||||||
// Download artefact JSON via backend (service-role) to avoid RLS issues
|
// Download artefact JSON via backend (service-role) to avoid RLS issues
|
||||||
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, {
|
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!jsonRes.ok) throw new Error(await jsonRes.text());
|
if (!jsonRes.ok) throw new Error(await jsonRes.text());
|
||||||
const doc: DoclingJson = await jsonRes.json();
|
const doc: DoclingJson = await jsonRes.json();
|
||||||
@@ -238,7 +239,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
run();
|
run();
|
||||||
}, [fileId]); /* eslint-disable-line react-hooks/exhaustive-deps */
|
}, [fileId]); /* eslint-disable-line react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
|
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
|
||||||
|
|
||||||
const pageProxyUrl = useMemo(() => {
|
const pageProxyUrl = useMemo(() => {
|
||||||
if (!manifest) return undefined;
|
if (!manifest) return undefined;
|
||||||
@@ -267,7 +268,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
setPageObjectUrl(cached);
|
setPageObjectUrl(cached);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
|
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok && manifest) {
|
if (!resp.ok && manifest) {
|
||||||
// Fallback to thumbnail if the full image is not accessible yet
|
// Fallback to thumbnail if the full image is not accessible yet
|
||||||
@@ -327,9 +328,9 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
{!hideToolbar && (
|
{!hideToolbar && (
|
||||||
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, borderBottom: '1px solid var(--color-divider)' }}>
|
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, borderBottom: '1px solid var(--color-divider)' }}>
|
||||||
<IconButton size="small" onClick={() => handlePageChange(start - 1)} disabled={start <= 1}><ArrowBackIosNewIcon fontSize="inherit" /></IconButton>
|
<IconButton size="small" onClick={() => handlePageChange(start - 1)} disabled={start <= 1}><ArrowBackIosNew fontSize="inherit" /></IconButton>
|
||||||
<Box sx={{ fontSize: 12, color: 'var(--color-text-2)' }}>Section {start}–{end}</Box>
|
<Box sx={{ fontSize: 12, color: 'var(--color-text-2)' }}>Section {start}–{end}</Box>
|
||||||
<IconButton size="small" onClick={() => handlePageChange(end + 1)} disabled={end >= totalPages}><ArrowForwardIosIcon fontSize="inherit" /></IconButton>
|
<IconButton size="small" onClick={() => handlePageChange(end + 1)} disabled={end >= totalPages}><ArrowForwardIos fontSize="inherit" /></IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, p: 2 }}>
|
<Box sx={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, p: 2 }}>
|
||||||
@@ -369,6 +370,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
export default CCDoclingViewer;
|
export default CCDoclingViewer;
|
||||||
|
|
||||||
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -376,7 +378,7 @@ const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
|||||||
let revoked: string | null = null;
|
let revoked: string | null = null;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { SelectChangeEvent } from '@mui/material/Select';
|
|||||||
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
|
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
|
||||||
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
|
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
|
||||||
import CCBundleViewer from './CCBundleViewer.tsx';
|
import CCBundleViewer from './CCBundleViewer.tsx';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type CanonicalDoclingConfig = {
|
type CanonicalDoclingConfig = {
|
||||||
pipeline: 'standard' | 'vlm' | 'asr';
|
pipeline: 'standard' | 'vlm' | 'asr';
|
||||||
@@ -46,6 +46,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const { fileId } = useParams<{ fileId: string }>();
|
const { fileId } = useParams<{ fileId: string }>();
|
||||||
|
|
||||||
const validFileId = useMemo(() => fileId || '', [fileId]);
|
const validFileId = useMemo(() => fileId || '', [fileId]);
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
|
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
|
||||||
const [profile, setProfile] = useState<Profile>('default');
|
const [profile, setProfile] = useState<Profile>('default');
|
||||||
@@ -147,8 +148,8 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadBundles = async () => {
|
const loadBundles = async () => {
|
||||||
if (!validFileId) return;
|
if (!validFileId) return;
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const arts: Artefact[] = await res.json();
|
const arts: Artefact[] = await res.json();
|
||||||
@@ -222,17 +223,17 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (!validFileId) return;
|
if (!validFileId) return;
|
||||||
setOutlineOptions([]);
|
setOutlineOptions([]);
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||||
try {
|
try {
|
||||||
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
|
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!artsRes.ok) return;
|
if (!artsRes.ok) return;
|
||||||
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
|
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
|
||||||
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
||||||
if (!outlineArt) return;
|
if (!outlineArt) return;
|
||||||
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!jsonRes.ok) return;
|
if (!jsonRes.ok) return;
|
||||||
const doc = await jsonRes.json();
|
const doc = await jsonRes.json();
|
||||||
@@ -242,7 +243,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const splitArt = arts.find(a => a.type === 'split_map_json');
|
const splitArt = arts.find(a => a.type === 'split_map_json');
|
||||||
if (splitArt) {
|
if (splitArt) {
|
||||||
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, {
|
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (smRes.ok) {
|
if (smRes.ok) {
|
||||||
const sm = await smRes.json();
|
const sm = await smRes.json();
|
||||||
@@ -485,8 +486,8 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
<Button variant="contained" disabled={busy || !validFileId} onClick={async () => {
|
<Button variant="contained" disabled={busy || !validFileId} onClick={async () => {
|
||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const body: CanonicalDoclingRequest = {
|
const body: CanonicalDoclingRequest = {
|
||||||
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
|
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import {
|
|||||||
Box, CircularProgress, IconButton, Typography, Collapse, Chip,
|
Box, CircularProgress, IconButton, Typography, Collapse, Chip,
|
||||||
List, ListItem, ListItemButton, ListItemIcon, ListItemText
|
List, ListItem, ListItemButton, ListItemIcon, ListItemText
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||||
ExpandMore, ChevronRight, Description, Check, Schedule,
|
import ChevronRight from '@mui/icons-material/ChevronRight';
|
||||||
Visibility, Psychology, Home as OverviewIcon
|
import Description from '@mui/icons-material/Description';
|
||||||
} from '@mui/icons-material';
|
import Check from '@mui/icons-material/Check';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import Schedule from '@mui/icons-material/Schedule';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import Psychology from '@mui/icons-material/Psychology';
|
||||||
|
import Overview from '@mui/icons-material/Home';
|
||||||
|
import CalendarViewMonth from '@mui/icons-material/CalendarViewMonth';
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type PageImagesManifest = {
|
type PageImagesManifest = {
|
||||||
@@ -62,6 +67,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
fileId, selectedPage, onSelectPage, currentSection
|
fileId, selectedPage, onSelectPage, currentSection
|
||||||
}) => {
|
}) => {
|
||||||
// State
|
// State
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
||||||
@@ -78,7 +84,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const API_BASE = useMemo(() =>
|
const API_BASE = useMemo(() =>
|
||||||
import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'),
|
import.meta.env.VITE_API_BASE || '/api',
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,7 +96,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
|
|
||||||
// Load page images manifest
|
// Load page images manifest
|
||||||
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
||||||
@@ -194,7 +200,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
|
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
@@ -345,7 +351,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
color={selectedView === 'overview' ? 'primary' : 'default'}
|
color={selectedView === 'overview' ? 'primary' : 'default'}
|
||||||
title="Processing Overview"
|
title="Processing Overview"
|
||||||
>
|
>
|
||||||
<OverviewIcon />
|
<CalendarViewMonth />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type PageImagesManifest = {
|
type PageImagesManifest = {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -54,6 +54,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
selectedPage: number;
|
selectedPage: number;
|
||||||
onSelectPage: (p: number) => void;
|
onSelectPage: (p: number) => void;
|
||||||
}> = ({ fileId, selectedPage, onSelectPage }) => {
|
}> = ({ fileId, selectedPage, onSelectPage }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
||||||
@@ -64,7 +65,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
const [showAdmin, setShowAdmin] = useState(false);
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
const [adminData, setAdminData] = useState<FileTasksResponse | null>(null);
|
const [adminData, setAdminData] = useState<FileTasksResponse | null>(null);
|
||||||
|
|
||||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
|
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
@@ -73,7 +74,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (!mRes.ok) throw new Error(await mRes.text());
|
if (!mRes.ok) throw new Error(await mRes.text());
|
||||||
const m: PageImagesManifest = await mRes.json();
|
const m: PageImagesManifest = await mRes.json();
|
||||||
@@ -82,14 +83,14 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
// Try to load outline structure artefact (for grouping only)
|
// Try to load outline structure artefact (for grouping only)
|
||||||
try {
|
try {
|
||||||
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (artsRes.ok) {
|
if (artsRes.ok) {
|
||||||
const arts: Array<{ id: string; type: string }> = await artsRes.json();
|
const arts: Array<{ id: string; type: string }> = await artsRes.json();
|
||||||
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
||||||
if (outlineArt) {
|
if (outlineArt) {
|
||||||
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
||||||
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
|
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||||
});
|
});
|
||||||
if (jsonRes.ok) {
|
if (jsonRes.ok) {
|
||||||
const outJson = await jsonRes.json();
|
const outJson = await jsonRes.json();
|
||||||
@@ -118,7 +119,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
const pg = manifest.page_images[idx];
|
const pg = manifest.page_images[idx];
|
||||||
if (!pg) return undefined;
|
if (!pg) return undefined;
|
||||||
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
|
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok) return undefined;
|
if (!resp.ok) return undefined;
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
@@ -150,7 +151,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
<IconButton size="small" onClick={async () => {
|
<IconButton size="small" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
setShowAdmin(true);
|
setShowAdmin(true);
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setAdminData(data);
|
setAdminData(data);
|
||||||
@@ -161,7 +162,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 1, flexShrink: 0, bgcolor: 'var(--color-panel)' }}>
|
<Box sx={{ p: 1, borderBottom: '1px solid var(--color-divider)', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 1, flexShrink: 0, bgcolor: 'var(--color-panel)' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<IconButton size="small" onClick={() => onSelectPage(Math.max(1, selectedPage - 1))}><ArrowBackIosNewIcon fontSize="inherit" /></IconButton>
|
<IconButton size="small" onClick={() => onSelectPage(Math.max(1, selectedPage - 1))}><ArrowBackIosNew fontSize="inherit" /></IconButton>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
value={selectedPage}
|
value={selectedPage}
|
||||||
@@ -170,7 +171,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
InputProps={{ sx: { width: 64, '& input': { textAlign: 'center', padding: '6px' } } }}
|
InputProps={{ sx: { width: 64, '& input': { textAlign: 'center', padding: '6px' } } }}
|
||||||
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', maxLength: 4 }}
|
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', maxLength: 4 }}
|
||||||
/>
|
/>
|
||||||
<IconButton size="small" onClick={() => onSelectPage(Math.min(manifest.page_count, selectedPage + 1))}><ArrowForwardIosIcon fontSize="inherit" /></IconButton>
|
<IconButton size="small" onClick={() => onSelectPage(Math.min(manifest.page_count, selectedPage + 1))}><ArrowForwardIos fontSize="inherit" /></IconButton>
|
||||||
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>/ {manifest.page_count}</Typography>
|
<Typography variant="body2" sx={{ color: 'var(--color-text-2)' }}>/ {manifest.page_count}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -186,8 +187,8 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
</Select>
|
</Select>
|
||||||
{outline && outline.sections.length > 0 && (
|
{outline && outline.sections.length > 0 && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMoreIcon fontSize="inherit" /></IconButton>
|
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMore fontSize="inherit" /></IconButton>
|
||||||
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRightIcon fontSize="inherit" /></IconButton>
|
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRight fontSize="inherit" /></IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -202,8 +203,8 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
<Typography variant="body2" sx={{ color: 'var(--color-text-2)', fontWeight: 600 }}>Sections</Typography>
|
<Typography variant="body2" sx={{ color: 'var(--color-text-2)', fontWeight: 600 }}>Sections</Typography>
|
||||||
<Box>
|
<Box>
|
||||||
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMoreIcon fontSize="inherit" /></IconButton>
|
<IconButton size="small" title="Expand all" onClick={() => setCollapsed(new Set())}><ExpandMore fontSize="inherit" /></IconButton>
|
||||||
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRightIcon fontSize="inherit" /></IconButton>
|
<IconButton size="small" title="Collapse all" onClick={() => setCollapsed(new Set(collectAllIds(outline.sections)))}><ChevronRight fontSize="inherit" /></IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -291,7 +292,7 @@ const SectionTile: React.FC<{
|
|||||||
borderBottom: '1px solid var(--color-divider)'
|
borderBottom: '1px solid var(--color-divider)'
|
||||||
}}>
|
}}>
|
||||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); toggleCollapse(s.id); }}>
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); toggleCollapse(s.id); }}>
|
||||||
{isCollapsed ? <ChevronRightIcon fontSize="inherit" /> : <ExpandMoreIcon fontSize="inherit" />}
|
{isCollapsed ? <ChevronRight fontSize="inherit" /> : <ExpandMore fontSize="inherit" />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box onClick={() => onSelectPage(Math.max(1, s.start_page))}>
|
<Box onClick={() => onSelectPage(Math.max(1, s.start_page))}>
|
||||||
<Typography component="span" sx={{ color: 'var(--color-text-1)' }}>{s.title}</Typography>
|
<Typography component="span" sx={{ color: 'var(--color-text-1)' }}>{s.title}</Typography>
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { TLShapeId } from '@tldraw/tldraw';
|
|
||||||
|
|
||||||
export interface AnnotationData {
|
|
||||||
studentIndex?: number; // undefined for exam/markscheme annotations
|
|
||||||
pageIndex: number;
|
|
||||||
shapeId: TLShapeId;
|
|
||||||
bounds: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AnnotationManager {
|
|
||||||
private examAnnotations: Set<TLShapeId> = new Set();
|
|
||||||
private markSchemeAnnotations: Set<TLShapeId> = new Set();
|
|
||||||
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
|
|
||||||
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
|
|
||||||
|
|
||||||
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
|
|
||||||
this.annotationData.set(shapeId, data);
|
|
||||||
|
|
||||||
if (data.studentIndex !== undefined) {
|
|
||||||
// Student response annotation
|
|
||||||
let studentSet = this.studentAnnotations.get(data.studentIndex);
|
|
||||||
if (!studentSet) {
|
|
||||||
studentSet = new Set();
|
|
||||||
this.studentAnnotations.set(data.studentIndex, studentSet);
|
|
||||||
}
|
|
||||||
studentSet.add(shapeId);
|
|
||||||
} else {
|
|
||||||
// Exam or mark scheme annotation
|
|
||||||
if (data.pageIndex < 0) {
|
|
||||||
this.examAnnotations.add(shapeId);
|
|
||||||
} else {
|
|
||||||
this.markSchemeAnnotations.add(shapeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAnnotation(shapeId: TLShapeId) {
|
|
||||||
const data = this.annotationData.get(shapeId);
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
if (data.studentIndex !== undefined) {
|
|
||||||
const studentSet = this.studentAnnotations.get(data.studentIndex);
|
|
||||||
studentSet?.delete(shapeId);
|
|
||||||
} else {
|
|
||||||
if (data.pageIndex < 0) {
|
|
||||||
this.examAnnotations.delete(shapeId);
|
|
||||||
} else {
|
|
||||||
this.markSchemeAnnotations.delete(shapeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.annotationData.delete(shapeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
|
|
||||||
return Array.from(this.studentAnnotations.get(studentIndex) || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForExam(): TLShapeId[] {
|
|
||||||
return Array.from(this.examAnnotations);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForMarkScheme(): TLShapeId[] {
|
|
||||||
return Array.from(this.markSchemeAnnotations);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
|
|
||||||
return this.annotationData.get(shapeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.examAnnotations.clear();
|
|
||||||
this.markSchemeAnnotations.clear();
|
|
||||||
this.studentAnnotations.clear();
|
|
||||||
this.annotationData.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Future transcription support
|
|
||||||
addTranscriptionToAnnotation(shapeId: TLShapeId) {
|
|
||||||
const data = this.annotationData.get(shapeId);
|
|
||||||
if (data) {
|
|
||||||
this.annotationData.set(shapeId, {
|
|
||||||
...data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user