Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7326b9f3be | ||
|
|
cd8ac38d39 | ||
|
|
e899af303d | ||
|
|
7a01b3e8f6 | ||
|
|
bff91a4b17 | ||
|
|
66f35b8ae4 | ||
|
|
fe5dbe7fa8 | ||
|
|
15a519748d | ||
|
|
3389fdcb5b | ||
|
|
ab35193be1 | ||
|
|
b3f71c5749 | ||
|
|
b396af96b9 | ||
|
|
3eac792ced | ||
|
|
8e8a345e61 | ||
|
|
2de3e29179 | ||
|
|
29390d30ca | ||
|
|
aa2f35e467 | ||
|
|
61a189a7a2 | ||
|
|
ab6f0b09d7 | ||
|
|
16ae3aa089 | ||
|
|
496ec2cbf9 | ||
|
|
dea3275f23 | ||
|
|
f067db3eb8 | ||
|
|
1ab8aab43c | ||
|
|
fdbc19cf0d | ||
|
|
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 |
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cursor*
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
'
|
||||||
+49
-5
@@ -1,8 +1,52 @@
|
|||||||
node_modules
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
/dist
|
||||||
|
/build
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment files (never commit secrets)
|
||||||
.env
|
.env
|
||||||
# Build output
|
.env.local
|
||||||
dist/
|
.env.*.local
|
||||||
|
.env.bak
|
||||||
|
|
||||||
# Lock files - clean install
|
# IDE
|
||||||
package-lock.json
|
.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')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+52
-4
@@ -1,24 +1,72 @@
|
|||||||
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 . .
|
||||||
# Run build with production mode
|
|
||||||
RUN npm run build -- --mode production
|
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
|
||||||
|
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
|
||||||
|
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
|
||||||
|
ARG VITE_API_BASE
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_APP_NAME
|
||||||
|
ARG VITE_APP_HMR_URL
|
||||||
|
ARG VITE_DEV
|
||||||
|
ARG VITE_FRONTEND_SITE_URL
|
||||||
|
ARG VITE_SEARCH_URL
|
||||||
|
ARG VITE_SUPABASE_ANON_KEY
|
||||||
|
ARG VITE_SUPABASE_URL
|
||||||
|
ARG VITE_SUPER_ADMIN_EMAIL
|
||||||
|
ARG VITE_TLSYNC_URL
|
||||||
|
ARG VITE_WHISPERLIVE_URL
|
||||||
|
# Run build with production mode. Keep these as build-step environment values
|
||||||
|
# rather than final-image ENV entries; Vite still embeds the public client config
|
||||||
|
# into the static bundle, but nginx image metadata does not need them.
|
||||||
|
RUN VITE_API_BASE="${VITE_API_BASE}" \
|
||||||
|
VITE_API_URL="${VITE_API_URL}" \
|
||||||
|
VITE_APP_NAME="${VITE_APP_NAME}" \
|
||||||
|
VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
|
||||||
|
VITE_DEV="${VITE_DEV}" \
|
||||||
|
VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
|
||||||
|
VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
|
||||||
|
VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
|
||||||
|
VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
|
||||||
|
VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
|
||||||
|
VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
|
||||||
|
VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
|
||||||
|
npm run build -- --mode production
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# .mjs files (pdfjs worker) must be served as application/javascript for module workers
|
||||||
|
RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
|
||||||
|
|
||||||
# Create a simple nginx configuration
|
# Create a simple nginx configuration
|
||||||
RUN echo 'server { \
|
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 = /health { \
|
||||||
|
proxy_pass http://192.168.0.64:18000/health; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
} \
|
||||||
|
location /api/ { \
|
||||||
|
proxy_pass http://192.168.0.64:18000/api/; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
} \
|
||||||
location / { \
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
expires 30d; \
|
expires 30d; \
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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:
|
||||||
|
VITE_API_BASE: ${VITE_API_BASE:-}
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
|
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
||||||
|
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
||||||
|
VITE_DEV: ${VITE_DEV:-false}
|
||||||
|
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
||||||
|
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
||||||
|
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
||||||
|
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-}
|
||||||
|
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
||||||
|
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
||||||
|
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
||||||
|
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
+14397
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -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",
|
||||||
@@ -82,7 +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",
|
||||||
"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;
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-13
@@ -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>.
|
||||||
|
* Must live inside TLDrawProvider so useTLDraw() reads live preferences.
|
||||||
|
* This activates the [data-color-mode="dark"] overrides in cc-design-system.css.
|
||||||
|
* tldraw itself uses tl-theme__dark class on .tl-container — we mirror that
|
||||||
|
* to the document root so --cc-* token overrides apply globally.
|
||||||
|
*/
|
||||||
|
const DarkModeSync: React.FC = () => {
|
||||||
|
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 }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<NeoUserProvider>
|
|
||||||
<NeoInstituteProvider>
|
|
||||||
<TLDrawProvider>
|
<TLDrawProvider>
|
||||||
|
<DarkModeSync />
|
||||||
|
<ThemeProvider theme={appTheme}>
|
||||||
|
<CssBaseline />
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
</ThemeProvider>
|
||||||
</TLDrawProvider>
|
</TLDrawProvider>
|
||||||
</NeoInstituteProvider>
|
|
||||||
</NeoUserProvider>
|
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
));
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 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,150 @@
|
|||||||
|
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>,
|
||||||
|
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
|
||||||
|
MarkSchemePage: () => <div>Mark Scheme editor</div>,
|
||||||
|
ExamMarkingPage: () => <div>Exam Marking</div>,
|
||||||
|
ExamResultsPage: () => <div>Exam Results</div>,
|
||||||
|
ResultsWidget: () => <div>Results Widget</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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('exam-marker routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseAuth.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the mark scheme editor route for authenticated users', () => {
|
||||||
|
mockUseAuth.mockReturnValue(authState({
|
||||||
|
user: { id: 'teacher-1', email: '[email protected]', user_type: 'email_teacher' },
|
||||||
|
user_role: 'email_teacher',
|
||||||
|
accessToken: 'token',
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderAt('/exam-marker/template-123/marks');
|
||||||
|
|
||||||
|
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
+84
-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, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } 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,44 @@ 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="/timetable/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="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:batchId/results" element={<ErrorBoundary><ExamResultsPage /></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 +198,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,333 +1,60 @@
|
|||||||
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
|
<IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small">
|
||||||
onClick={goBack}
|
|
||||||
disabled={!canGoBack || isDisabled}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ArrowBackIcon fontSize="small" />
|
<ArrowBackIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
@@ -337,7 +64,7 @@ export const GraphNavigator: React.FC = () => {
|
|||||||
<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" />
|
||||||
@@ -347,112 +74,48 @@ export const GraphNavigator: React.FC = () => {
|
|||||||
|
|
||||||
<Tooltip title="Forward">
|
<Tooltip title="Forward">
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton onClick={goForward} disabled={!canGoForward || isLoading} size="small">
|
||||||
onClick={goForward}
|
|
||||||
disabled={!canGoForward || isDisabled}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ArrowForwardIcon fontSize="small" />
|
<ArrowForwardIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</NavigationControls>
|
|
||||||
|
|
||||||
{/* History Menu */}
|
{currentNode && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={getNodeIcon(currentNode.type)}
|
||||||
|
label={currentNode.label || currentNode.type}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ maxWidth: 200, fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<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/";
|
||||||
+77
-140
@@ -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 });
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event) {
|
if (event === 'SIGNED_IN') {
|
||||||
case 'SIGNED_IN':
|
|
||||||
case 'TOKEN_REFRESHED':
|
|
||||||
case 'INITIAL_SESSION': {
|
|
||||||
persistSession(session ?? null);
|
persistSession(session ?? null);
|
||||||
|
setIsAuthResolving(!!session?.user);
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUser(resolvedUser);
|
setUser(resolvedUser);
|
||||||
setUserRole(role);
|
setUserRole(role);
|
||||||
|
setAccessToken(session.access_token ?? null);
|
||||||
|
await loadBootstrap(session.access_token);
|
||||||
} catch (buildError) {
|
} catch (buildError) {
|
||||||
logger.error('auth-context', '❌ Failed to build user from session', {
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||||
event,
|
|
||||||
error: buildError,
|
|
||||||
});
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
if (isMounted) {
|
setAccessToken(null);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setIsAuthResolving(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
break;
|
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
||||||
}
|
persistSession(session ?? null);
|
||||||
case 'SIGNED_OUT': {
|
setIsAuthResolving(!!session?.user);
|
||||||
persistSession(null);
|
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);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
if (isMounted) {
|
setAccessToken(null);
|
||||||
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
setUserRole(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setIsAuthResolving(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
if (event === 'SIGNED_OUT') {
|
||||||
default:
|
persistSession(null);
|
||||||
break;
|
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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([
|
||||||
|
navigationStore.switchContext({
|
||||||
main: 'profile',
|
main: 'profile',
|
||||||
base: 'profile',
|
base: 'profile',
|
||||||
extended: 'overview'
|
extended: 'overview'
|
||||||
}, userDb, profile.school_db_name);
|
}, 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) {
|
||||||
|
|||||||
+27
-128
@@ -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,73 +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,
|
||||||
// Set loading state when we start the actual database query
|
email: userInfo.email,
|
||||||
setLoading(true);
|
user_type: fastMetadata?.user_type || '',
|
||||||
|
username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user',
|
||||||
// Query profiles table without timeout to see actual error
|
display_name: String(fastMetadata?.display_name || ''),
|
||||||
logger.debug('user-context', '🔧 Step 5b: Starting profiles query...', {
|
school_id: null,
|
||||||
userId: userInfo.id,
|
created_at: userInfo.created_at,
|
||||||
clientType: 'authenticated'
|
updated_at: userInfo.updated_at
|
||||||
});
|
};
|
||||||
|
if (mountedRef.current && !isInitialized) {
|
||||||
logger.debug('user-context', '🔧 Step 5b1: About to make profiles query...', {
|
setProfile(fastProfile);
|
||||||
userId: userInfo.id,
|
setLoading(false);
|
||||||
queryStarted: true
|
setIsInitialized(true);
|
||||||
});
|
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name).
|
||||||
|
// No setLoading toggles — spinner is already cleared above.
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', userInfo.id)
|
.eq('id', userInfo.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
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,
|
||||||
@@ -186,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
|
||||||
};
|
};
|
||||||
@@ -282,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) {
|
||||||
@@ -309,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);
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -111,6 +112,7 @@ export type LogCategory =
|
|||||||
| 'user-context'
|
| 'user-context'
|
||||||
| 'neo-user-context'
|
| 'neo-user-context'
|
||||||
| 'neo-institute-context'
|
| 'neo-institute-context'
|
||||||
|
| 'cc-live-transcription'
|
||||||
| 'search-service';
|
| 'search-service';
|
||||||
|
|
||||||
interface LogConfig {
|
interface LogConfig {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+179
-149
@@ -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`,
|
|
||||||
height: `${HEADER_HEIGHT}px`,
|
|
||||||
gap: 2,
|
|
||||||
px: { xs: 1, sm: 2 }
|
|
||||||
}}>
|
|
||||||
<Box sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 2,
|
height: '100%'
|
||||||
minWidth: { xs: 'auto', sm: '200px' }
|
|
||||||
}}>
|
}}>
|
||||||
|
{showGraphNavigation && (
|
||||||
|
<GraphNavigator />
|
||||||
|
)}
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
component="div"
|
component="div"
|
||||||
className="app-title"
|
|
||||||
sx={{
|
sx={{
|
||||||
|
flexGrow: showGraphNavigation ? 0 : 1,
|
||||||
|
fontWeight: 600,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: theme.palette.text.primary,
|
display: 'flex',
|
||||||
'&:hover': {
|
alignItems: 'center',
|
||||||
color: theme.palette.primary.main
|
gap: 1
|
||||||
},
|
|
||||||
fontSize: { xs: '1rem', sm: '1.25rem' }
|
|
||||||
}}
|
}}
|
||||||
onClick={() => navigate(isAuthenticated ? '/dashboard' : '/')}
|
onClick={() => handleNavigation('/')}
|
||||||
>
|
>
|
||||||
|
<Home sx={{ color: theme.palette.primary.main }} />
|
||||||
Classroom Copilot
|
Classroom Copilot
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
position: 'absolute',
|
{isAuthenticated && bootstrapData?.onboarding?.next_step && bootstrapData.onboarding.next_step !== 'ready' && (
|
||||||
left: '50%',
|
<Chip
|
||||||
transform: 'translateX(-50%)',
|
label={bootstrapData.onboarding.next_step.replace(/_/g, ' ')}
|
||||||
display: 'flex',
|
color="warning"
|
||||||
justifyContent: 'center',
|
size="small"
|
||||||
visibility: showGraphNavigation ? 'visible' : 'hidden',
|
onClick={() => navigate('/dashboard')}
|
||||||
width: {
|
sx={{ cursor: 'pointer', display: { xs: 'none', sm: 'flex' } }}
|
||||||
xs: 'calc(100% - 160px)', // More space for menu and title
|
/>
|
||||||
sm: 'calc(100% - 200px)', // Standard spacing
|
)}
|
||||||
md: 'auto' // Full width on medium and up
|
{isAuthenticated && (
|
||||||
},
|
<Typography
|
||||||
maxWidth: '800px',
|
variant="body2"
|
||||||
'& .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 />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
minWidth: { xs: 'auto', sm: '200px' },
|
|
||||||
ml: 'auto'
|
|
||||||
}}>
|
|
||||||
<IconButton
|
|
||||||
className="menu-button"
|
|
||||||
color="inherit"
|
|
||||||
onClick={handleMenuOpen}
|
|
||||||
edge="end"
|
|
||||||
sx={{
|
sx={{
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
display: { xs: 'none', sm: 'block' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.email}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="menu"
|
||||||
|
onClick={handleMenuOpen}
|
||||||
|
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: {
|
|
||||||
elevation: 3,
|
|
||||||
sx: {
|
sx: {
|
||||||
bgcolor: theme.palette.background.paper,
|
width: 280,
|
||||||
color: theme.palette.text.primary,
|
maxHeight: '80vh'
|
||||||
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={{
|
||||||
|
px: 2, py: 1,
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
School Admin
|
||||||
|
</Typography>,
|
||||||
|
<MenuItem key="school-settings" onClick={() => handleNavigation('/school-settings')}>
|
||||||
|
<ListItemIcon><SchoolSettings /></ListItemIcon>
|
||||||
|
<ListItemText primary="School Settings" secondary="Calendar, overview" />
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
<MenuItem key="search" onClick={() => handleNavigation('/search')}>
|
<MenuItem key="staff-manager" onClick={() => handleNavigation('/staff-manager')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon><People /></ListItemIcon>
|
||||||
<SearchIcon />
|
<ListItemText primary="Staff Manager" secondary="Invite & manage teachers" />
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Search" />
|
|
||||||
</MenuItem>,
|
</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>
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, 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 ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import GradingIcon from '@mui/icons-material/Grading';
|
||||||
|
|
||||||
|
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 VERSION_SEPARATOR = ' · ';
|
||||||
|
const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/;
|
||||||
|
|
||||||
|
type DialogMode = 'create' | 'edit' | 'duplicate';
|
||||||
|
|
||||||
|
type TemplateDialogState = {
|
||||||
|
mode: DialogMode;
|
||||||
|
template?: ExamTemplate;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
function splitTemplateTitle(title: string): { name: string; version: string } {
|
||||||
|
const parts = title.split(VERSION_SEPARATOR);
|
||||||
|
const possibleVersion = parts[parts.length - 1]?.trim() ?? '';
|
||||||
|
if (parts.length > 1 && VERSION_RE.test(possibleVersion)) {
|
||||||
|
return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion };
|
||||||
|
}
|
||||||
|
return { name: title, version: 'v1' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeTemplateTitle(name: string, version: string): string {
|
||||||
|
const cleanName = name.trim();
|
||||||
|
const cleanVersion = version.trim();
|
||||||
|
return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextVersionLabel(version: string): string {
|
||||||
|
const match = version.trim().match(VERSION_RE);
|
||||||
|
if (!match) return 'v2';
|
||||||
|
const segments = match[1].split('.');
|
||||||
|
const last = Number(segments[segments.length - 1]);
|
||||||
|
segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2';
|
||||||
|
return `v${segments.join('.')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paperKey(t: ExamTemplate): string {
|
||||||
|
return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper';
|
||||||
|
}
|
||||||
|
|
||||||
|
function paperLabel(t: ExamTemplate): string {
|
||||||
|
if (t.exam_code) return t.exam_code;
|
||||||
|
if (t.subject) return t.subject;
|
||||||
|
if (t.source_file_id) return 'Uploaded paper';
|
||||||
|
return 'Custom paper';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [dialog, setDialog] = useState<TemplateDialogState>(null);
|
||||||
|
const [templateName, setTemplateName] = useState('');
|
||||||
|
const [version, setVersion] = useState('v1');
|
||||||
|
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 groupedTemplates = useMemo(() => {
|
||||||
|
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
|
||||||
|
templates.forEach((template) => {
|
||||||
|
const key = paperKey(template);
|
||||||
|
const existing = groups.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.templates.push(template);
|
||||||
|
} else {
|
||||||
|
groups.set(key, { label: paperLabel(template), templates: [template] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
|
||||||
|
}, [templates]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setTemplateName('New template');
|
||||||
|
setVersion('v1');
|
||||||
|
setSubject('');
|
||||||
|
setDialog({ mode: 'create' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const parsed = splitTemplateTitle(template.title);
|
||||||
|
setTemplateName(parsed.name);
|
||||||
|
setVersion(parsed.version);
|
||||||
|
setSubject(template.subject ?? '');
|
||||||
|
setDialog({ mode: 'edit', template });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const parsed = splitTemplateTitle(template.title);
|
||||||
|
setTemplateName(parsed.name);
|
||||||
|
setVersion(nextVersionLabel(parsed.version));
|
||||||
|
setSubject(template.subject ?? '');
|
||||||
|
setDialog({ mode: 'duplicate', template });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
if (!saving) setDialog(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDialog = async () => {
|
||||||
|
if (!dialog || !templateName.trim()) return;
|
||||||
|
const title = composeTemplateTitle(templateName, version);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (dialog.mode === 'create') {
|
||||||
|
const created = await examRepository.createTemplate({
|
||||||
|
title,
|
||||||
|
subject: subject.trim() || undefined,
|
||||||
|
institute_id: instituteId,
|
||||||
|
});
|
||||||
|
setDialog(null);
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dialog.template) return;
|
||||||
|
|
||||||
|
if (dialog.mode === 'duplicate') {
|
||||||
|
const created = await examRepository.duplicateTemplate(dialog.template.id, title);
|
||||||
|
setTemplates((prev) => [created, ...prev]);
|
||||||
|
setDialog(null);
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
|
||||||
|
title,
|
||||||
|
subject: subject.trim() || null,
|
||||||
|
});
|
||||||
|
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
|
setDialog(null);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const parsed = splitTemplateTitle(template.title);
|
||||||
|
if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await examRepository.archiveTemplate(template.id);
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogTitle = dialog?.mode === 'edit'
|
||||||
|
? 'Rename template / edit version'
|
||||||
|
: dialog?.mode === 'duplicate'
|
||||||
|
? 'Duplicate as new version'
|
||||||
|
: 'New exam template';
|
||||||
|
|
||||||
|
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: 620 }}>
|
||||||
|
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
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 named template to start mapping an exam paper.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{groupedTemplates.map((group) => (
|
||||||
|
<Box key={group.key}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="h6">{group.label}</Typography>
|
||||||
|
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
|
||||||
|
</Stack>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{group.templates.map((t) => {
|
||||||
|
const parsed = splitTemplateTitle(t.title);
|
||||||
|
return (
|
||||||
|
<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', gap: 1 }}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
|
||||||
|
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
<Tooltip title="Rename / edit version">
|
||||||
|
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Duplicate as new version">
|
||||||
|
<IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Archive">
|
||||||
|
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
|
||||||
|
<ArchiveIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</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">
|
||||||
|
Updated {new Date(t.updated_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
||||||
|
startIcon={<GradingIcon fontSize="small" />}
|
||||||
|
>
|
||||||
|
Edit marks
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Template name"
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
helperText="User-facing name. Several templates can share the same paper."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Version"
|
||||||
|
value={version}
|
||||||
|
onChange={(e) => setVersion(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
helperText="Stored in the template title until the API grows a dedicated version column."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Paper / subject label"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
disabled={dialog?.mode === 'duplicate'}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
||||||
|
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamDashboardPage;
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { v5 as uuidv5 } from 'uuid';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||||
|
|
||||||
|
const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0';
|
||||||
|
|
||||||
|
function stableMarkId(batchId: string, submissionId: string, questionId: string) {
|
||||||
|
return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamMarkingPage: React.FC = () => {
|
||||||
|
const { batchId } = useParams<{ batchId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [queue, setQueue] = useState<BatchQueueResponse | null>(null);
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [marks, setMarks] = useState<Record<string, string>>({});
|
||||||
|
const [comments, setComments] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!batchId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const nextQueue = await examRepository.getBatchQueue(batchId);
|
||||||
|
setQueue(nextQueue);
|
||||||
|
setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id));
|
||||||
|
setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [batchId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const markableQuestions = useMemo(
|
||||||
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
||||||
|
[template],
|
||||||
|
);
|
||||||
|
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
const saveSelected = async () => {
|
||||||
|
if (!batchId || !selected) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const writes = markableQuestions
|
||||||
|
.map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] }))
|
||||||
|
.filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim());
|
||||||
|
for (const { q, raw, comment } of writes) {
|
||||||
|
const awarded = raw === undefined || raw === '' ? 0 : Number(raw);
|
||||||
|
if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) {
|
||||||
|
throw new Error(`Invalid mark for ${q.label}`);
|
||||||
|
}
|
||||||
|
await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), {
|
||||||
|
submission_id: selected.id,
|
||||||
|
question_id: q.id,
|
||||||
|
awarded_marks: awarded,
|
||||||
|
comment: comment?.trim() || undefined,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`);
|
||||||
|
setMarks({});
|
||||||
|
setComments({});
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseStudent = (submission: StudentSubmission) => {
|
||||||
|
setSelectedId(submission.id);
|
||||||
|
setMarks({});
|
||||||
|
setComments({});
|
||||||
|
setMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !queue) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
||||||
|
Back to Exam Marker
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h4" component="h1" fontWeight={700}>{queue?.batch.title || 'Mark exam'}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="outlined" startIcon={<TableChartIcon />} onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
|
||||||
|
Results
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
{message && <Alert severity="success" onClose={() => setMessage(null)}>{message}</Alert>}
|
||||||
|
{markableQuestions.length === 0 && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
This template has no markable parts yet. Add parts in template setup before entering marks.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||||
|
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Marking queue</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
||||||
|
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||||
|
<Chip size="small" label={`${queue?.progress.complete ?? 0} complete`} color="success" variant="outlined" />
|
||||||
|
</Stack>
|
||||||
|
<List dense disablePadding>
|
||||||
|
{(queue?.submissions ?? []).map((submission) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={submission.id}
|
||||||
|
selected={submission.id === selectedId}
|
||||||
|
onClick={() => chooseStudent(submission)}
|
||||||
|
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={submission.student_name || submission.student_id || 'Unknown student'}
|
||||||
|
secondary={`${submission.status} · ${submission.mark_entry_count ?? 0} marks`}
|
||||||
|
/>
|
||||||
|
<Chip size="small" label={submission.status} color={submission.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="outlined" sx={{ flex: 1 }}>
|
||||||
|
<CardContent>
|
||||||
|
{selected ? (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">{selected.student_name || selected.student_id || 'Unknown student'}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Status: {selected.status}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
{markableQuestions.map((q: ExamQuestion) => (
|
||||||
|
<Stack key={q.id} direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||||
|
<Box sx={{ minWidth: 150 }}>
|
||||||
|
<Typography variant="body2" fontWeight={700}>{q.label}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">/ {q.max_marks} marks</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Mark"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0, max: q.max_marks, step: 0.5 }}
|
||||||
|
value={marks[q.id] ?? ''}
|
||||||
|
onChange={(e) => setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
||||||
|
sx={{ width: { xs: '100%', sm: 120 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Comment"
|
||||||
|
size="small"
|
||||||
|
value={comments[q.id] ?? ''}
|
||||||
|
onChange={(e) => setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button onClick={() => navigate(`/exam-marker/${batchId}/results`)}>Skip to results</Button>
|
||||||
|
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
|
||||||
|
{saving ? 'Saving…' : 'Save marks'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No submissions in this batch.</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamMarkingPage;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { BatchResultsResponse } from '../../types/exam.types';
|
||||||
|
|
||||||
|
function formatMark(value: number | null | undefined) {
|
||||||
|
return value === null || value === undefined ? '' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamResultsPage: React.FC = () => {
|
||||||
|
const { batchId } = useParams<{ batchId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<BatchResultsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!batchId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setData(await examRepository.getBatchResults(batchId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [batchId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const rows = data?.results ?? [];
|
||||||
|
const presentTotals = rows
|
||||||
|
.map((r) => r.total)
|
||||||
|
.filter((v): v is number => typeof v === 'number');
|
||||||
|
const average = presentTotals.length
|
||||||
|
? presentTotals.reduce((sum, v) => sum + v, 0) / presentTotals.length
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
absent: rows.filter((r) => r.status === 'absent' && r.total === null).length,
|
||||||
|
marked: rows.filter((r) => r.total !== null).length,
|
||||||
|
average,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const downloadCsv = async () => {
|
||||||
|
if (!batchId) return;
|
||||||
|
setDownloading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const csv = await examRepository.getBatchCsv(batchId);
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `batch-${batchId}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error">{error || 'Results not found'}</Alert>
|
||||||
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
||||||
|
Back to Exam Marker
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h4" component="h1" fontWeight={700}>
|
||||||
|
{data.batch.title || 'Exam results'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button variant="outlined" startIcon={<EditIcon />} onClick={() => navigate(`/exam-marker/${data.batch.id}/mark`)}>
|
||||||
|
Mark
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={downloading}>
|
||||||
|
{downloading ? 'Preparing…' : 'Download CSV'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip label={`${summary.total} students`} />
|
||||||
|
<Chip label={`${summary.marked} with marks`} color="success" variant="outlined" />
|
||||||
|
<Chip label={`${summary.absent} absent/no scan`} color="warning" variant="outlined" />
|
||||||
|
<Chip label={`Class average ${summary.average === null ? '—' : summary.average.toFixed(1)}`} color="primary" />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Student</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
{data.questions.map((q) => (
|
||||||
|
<TableCell key={q.id} align="right">{q.label} / {q.max_marks}</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">Total</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data.results.map((row) => (
|
||||||
|
<TableRow key={row.submission_id} sx={row.status === 'absent' && row.total === null ? { opacity: 0.72 } : undefined}>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={600}>{row.student_name || row.student_id || 'Unknown student'}</Typography>
|
||||||
|
{row.student_id && <Typography variant="caption" color="text.secondary">{row.student_id}</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={row.status || 'unknown'} color={row.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
||||||
|
</TableCell>
|
||||||
|
{data.questions.map((q) => (
|
||||||
|
<TableCell key={q.id} align="right">{formatMark(row.marks[q.id])}</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right"><strong>{formatMark(row.total)}</strong></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamResultsPage;
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
FormControl,
|
||||||
|
Grid,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import SyncIcon from '@mui/icons-material/Sync';
|
||||||
|
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type {
|
||||||
|
ExamQuestion,
|
||||||
|
ExamTemplateDetail,
|
||||||
|
MarkScheme,
|
||||||
|
MarkSchemeType,
|
||||||
|
SpecPoint,
|
||||||
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
|
type Drafts = Record<string, PartDraft>;
|
||||||
|
|
||||||
|
interface PartDraft {
|
||||||
|
max_marks: string;
|
||||||
|
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
|
||||||
|
spec_ref: string;
|
||||||
|
schemeType: MarkSchemeType;
|
||||||
|
body: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['written', 'short', 'mcq', 'diagram'];
|
||||||
|
const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [
|
||||||
|
{ value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' },
|
||||||
|
{ value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' },
|
||||||
|
{ value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' },
|
||||||
|
{ value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' },
|
||||||
|
{ value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function deriveSpecCode(template: ExamTemplateDetail | null): string {
|
||||||
|
const examCode = template?.exam_code ?? '';
|
||||||
|
const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i);
|
||||||
|
return match?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeToDraft(scheme: MarkScheme | null | undefined): Pick<PartDraft, 'schemeType' | 'body' | 'notes'> {
|
||||||
|
const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points';
|
||||||
|
const notes = typeof scheme?.notes === 'string' ? scheme.notes : '';
|
||||||
|
|
||||||
|
if (type === 'levels' && Array.isArray(scheme?.levels)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'levels',
|
||||||
|
body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'parts' && Array.isArray(scheme?.parts)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'parts',
|
||||||
|
body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'checklist' && Array.isArray(scheme?.checklist)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'checklist',
|
||||||
|
body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'free') {
|
||||||
|
return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes };
|
||||||
|
}
|
||||||
|
if (Array.isArray(scheme?.points)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'points',
|
||||||
|
body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { schemeType: type, body: '', notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string, fallback = 0): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLine(line: string): string[] {
|
||||||
|
return line.split('|').map((part) => part.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftToScheme(draft: PartDraft): MarkScheme {
|
||||||
|
const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined };
|
||||||
|
if (draft.schemeType === 'levels') {
|
||||||
|
return {
|
||||||
|
type: 'levels',
|
||||||
|
levels: lines.map((line, idx) => {
|
||||||
|
const [level, range, descriptor] = splitLine(line);
|
||||||
|
const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
level: level || `L${idx + 1}`,
|
||||||
|
min: parseNumber(minRaw, 0),
|
||||||
|
max: parseNumber(maxRaw, parseNumber(minRaw, 0)),
|
||||||
|
descriptor: descriptor || line,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (draft.schemeType === 'parts') {
|
||||||
|
return {
|
||||||
|
type: 'parts',
|
||||||
|
parts: lines.map((line, idx) => {
|
||||||
|
const [label, marks, guidance] = splitLine(line);
|
||||||
|
return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (draft.schemeType === 'checklist') {
|
||||||
|
return {
|
||||||
|
type: 'checklist',
|
||||||
|
checklist: lines.map((line) => {
|
||||||
|
const [marks, text] = splitLine(line);
|
||||||
|
return { marks: parseNumber(marks, 1), text: text || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'points',
|
||||||
|
points: lines.map((line) => {
|
||||||
|
const [mark, text] = splitLine(line);
|
||||||
|
return { mark: parseNumber(mark, 1), text: text || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDraft(question: ExamQuestion): PartDraft {
|
||||||
|
const scheme = schemeToDraft(question.mark_scheme);
|
||||||
|
return {
|
||||||
|
max_marks: String(question.max_marks ?? 0),
|
||||||
|
answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written',
|
||||||
|
spec_ref: question.spec_ref ?? '',
|
||||||
|
schemeType: scheme.schemeType,
|
||||||
|
body: scheme.body,
|
||||||
|
notes: scheme.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkSchemePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { templateId } = useParams<{ templateId: string }>();
|
||||||
|
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<Drafts>({});
|
||||||
|
const [specCode, setSpecCode] = useState('');
|
||||||
|
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [specEndpointMissing, setSpecEndpointMissing] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!templateId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const detail = await examRepository.getTemplate(templateId);
|
||||||
|
const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
||||||
|
setTemplate(detail);
|
||||||
|
setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null);
|
||||||
|
setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)])));
|
||||||
|
setSpecCode((prev) => prev || deriveSpecCode(detail));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const loadSpecPoints = useCallback(async () => {
|
||||||
|
if (!specCode.trim()) return;
|
||||||
|
setSpecEndpointMissing(false);
|
||||||
|
try {
|
||||||
|
setSpecPoints(await examRepository.listSpecPoints(specCode.trim()));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode });
|
||||||
|
setSpecEndpointMissing(true);
|
||||||
|
setSpecPoints([]);
|
||||||
|
}
|
||||||
|
}, [specCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSpecPoints();
|
||||||
|
}, [loadSpecPoints]);
|
||||||
|
|
||||||
|
const parts = useMemo(
|
||||||
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
|
||||||
|
[template],
|
||||||
|
);
|
||||||
|
const selected = parts.find((p) => p.id === selectedId) ?? null;
|
||||||
|
const draft = selected ? drafts[selected.id] : null;
|
||||||
|
const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null;
|
||||||
|
|
||||||
|
const updateDraft = (questionId: string, patch: Partial<PartDraft>) => {
|
||||||
|
setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePart = async (question: ExamQuestion) => {
|
||||||
|
const partDraft = drafts[question.id];
|
||||||
|
if (!partDraft) return;
|
||||||
|
setSavingId(question.id);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
await examRepository.patchQuestion(question.id, {
|
||||||
|
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||||
|
answer_type: partDraft.answer_type,
|
||||||
|
mark_scheme: draftToScheme(partDraft),
|
||||||
|
spec_ref: partDraft.spec_ref.trim() || null,
|
||||||
|
});
|
||||||
|
setTemplate((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
questions: prev.questions.map((q) => q.id === question.id ? {
|
||||||
|
...q,
|
||||||
|
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||||
|
answer_type: partDraft.answer_type,
|
||||||
|
mark_scheme: draftToScheme(partDraft),
|
||||||
|
spec_ref: partDraft.spec_ref.trim() || null,
|
||||||
|
} : q),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setNotice(`Saved mark scheme for Part ${question.label}.`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncGraph = async () => {
|
||||||
|
if (!templateId) return;
|
||||||
|
setSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const res = await examRepository.syncTemplateToGraph(templateId);
|
||||||
|
setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Back to templates
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
|
||||||
|
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
|
||||||
|
{syncing ? 'Syncing…' : 'Sync to graph'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
|
||||||
|
{specEndpointMissing && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
|
<Grid container spacing={2} alignItems="center">
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<TextField
|
||||||
|
label="Specification code"
|
||||||
|
value={specCode}
|
||||||
|
onChange={(e) => setSpecCode(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={8}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint).
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{parts.length === 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={3}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Parts</Typography>
|
||||||
|
<Tabs
|
||||||
|
orientation="vertical"
|
||||||
|
value={selectedId ?? false}
|
||||||
|
onChange={(_, value) => setSelectedId(value)}
|
||||||
|
variant="scrollable"
|
||||||
|
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
|
||||||
|
>
|
||||||
|
{parts.map((part) => (
|
||||||
|
<Tab
|
||||||
|
key={part.id}
|
||||||
|
value={part.id}
|
||||||
|
label={
|
||||||
|
<Box sx={{ textAlign: 'left', width: '100%' }}>
|
||||||
|
<Typography variant="body2">Part {part.label}</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
|
||||||
|
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={9}>
|
||||||
|
{selected && draft && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5">Part {selected.label}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
disabled={savingId === selected.id}
|
||||||
|
onClick={() => savePart(selected)}
|
||||||
|
>
|
||||||
|
{savingId === selected.id ? 'Saving…' : 'Save part'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<TextField
|
||||||
|
label="Max marks"
|
||||||
|
value={draft.max_marks}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 0, step: 0.5 }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="answer-type-label">Answer type</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="answer-type-label"
|
||||||
|
label="Answer type"
|
||||||
|
value={draft.answer_type}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
|
||||||
|
>
|
||||||
|
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="scheme-type-label"
|
||||||
|
label="Scheme form"
|
||||||
|
value={draft.schemeType}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
|
||||||
|
>
|
||||||
|
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
options={specPoints}
|
||||||
|
value={selectedSpecPoint}
|
||||||
|
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
|
||||||
|
getOptionLabel={(option) => `${option.ref} — ${option.description}`}
|
||||||
|
isOptionEqualToValue={(option, value) => option.ref === value.ref}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="SpecPoint picker"
|
||||||
|
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="spec_ref"
|
||||||
|
value={draft.spec_ref}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { spec_ref: e.target.value })}
|
||||||
|
helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint."
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={`${SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`}
|
||||||
|
value={draft.body}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { body: e.target.value })}
|
||||||
|
helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper}
|
||||||
|
multiline
|
||||||
|
minRows={10}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Internal marking notes"
|
||||||
|
value={draft.notes}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkSchemePage;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { BatchResultsResponse, ExamTemplate, MarkingBatch } from '../../types/exam.types';
|
||||||
|
|
||||||
|
interface ResultsWidgetProps {
|
||||||
|
classId: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageFromResults(results: BatchResultsResponse | null) {
|
||||||
|
const totals = (results?.results ?? [])
|
||||||
|
.map((row) => row.total)
|
||||||
|
.filter((value): value is number => typeof value === 'number');
|
||||||
|
if (!totals.length) return null;
|
||||||
|
return totals.reduce((sum, value) => sum + value, 0) / totals.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResultsWidget: React.FC<ResultsWidgetProps> = ({ classId, className }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
||||||
|
const [batches, setBatches] = useState<MarkingBatch[]>([]);
|
||||||
|
const [latestResults, setLatestResults] = useState<BatchResultsResponse | null>(null);
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [nextTemplates, nextBatches] = await Promise.all([
|
||||||
|
examRepository.listTemplates(),
|
||||||
|
examRepository.listBatches(),
|
||||||
|
]);
|
||||||
|
const classBatches = nextBatches
|
||||||
|
.filter((batch) => batch.class_id === classId)
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
setTemplates(nextTemplates);
|
||||||
|
setSelectedTemplateId((current) => current || nextTemplates[0]?.id || '');
|
||||||
|
setBatches(classBatches);
|
||||||
|
setLatestResults(classBatches[0] ? await examRepository.getBatchResults(classBatches[0].id) : null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [classId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const latestBatch = batches[0] ?? null;
|
||||||
|
const average = useMemo(() => averageFromResults(latestResults), [latestResults]);
|
||||||
|
const absent = latestResults?.results.filter((row) => row.status === 'absent' && row.total === null).length ?? 0;
|
||||||
|
|
||||||
|
const createBatch = async () => {
|
||||||
|
if (!selectedTemplateId) return;
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const template = templates.find((item) => item.id === selectedTemplateId);
|
||||||
|
const created = await examRepository.createBatch({
|
||||||
|
template_id: selectedTemplateId,
|
||||||
|
class_id: classId,
|
||||||
|
title: `${className || 'Class'} · ${template?.title || 'Exam'}`,
|
||||||
|
});
|
||||||
|
navigate(`/exam-marker/${created.id}/mark`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<AssessmentIcon color="primary" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">Assessment results</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Last exam summary for this class</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{loading && <CircularProgress size={22} />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="warning" onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
|
||||||
|
{latestBatch ? (
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight={700}>{latestBatch.title || 'Exam batch'}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(latestBatch.created_at).toLocaleDateString('en-GB')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip size="small" color="primary" label={`Average ${average === null ? '—' : average.toFixed(1)}`} />
|
||||||
|
<Chip size="small" variant="outlined" label={`${latestResults?.results.length ?? 0} students`} />
|
||||||
|
<Chip size="small" color="warning" variant="outlined" label={`${absent} absent`} />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button size="small" variant="contained" startIcon={<TableChartIcon />} onClick={() => navigate(`/exam-marker/${latestBatch.id}/results`)}>
|
||||||
|
View results
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => navigate(`/exam-marker/${latestBatch.id}/mark`)}>
|
||||||
|
Continue marking
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : !loading ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No exam batches have been created for this class yet.
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label="Template"
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||||
|
disabled={!templates.length || creating}
|
||||||
|
sx={{ minWidth: 260 }}
|
||||||
|
>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<MenuItem key={template.id} value={template.id}>{template.title}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<PlayArrowIcon />}
|
||||||
|
onClick={createBatch}
|
||||||
|
disabled={!selectedTemplateId || creating}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating…' : 'Create marking batch'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResultsWidget;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
|
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||||
|
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||||
|
export { default as ExamMarkingPage } from './ExamMarkingPage';
|
||||||
|
export { default as ExamResultsPage } from './ExamResultsPage';
|
||||||
|
export { default as ResultsWidget } from './ResultsWidget';
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
||||||
|
import SaveIcon from '@mui/icons-material/Save'
|
||||||
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
|
import { Editor, Tldraw, createShapeId, 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 { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||||
|
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||||
|
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{ id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const },
|
||||||
|
{ id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const },
|
||||||
|
{ id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const },
|
||||||
|
{ id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const },
|
||||||
|
{ id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const },
|
||||||
|
{ id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const },
|
||||||
|
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const },
|
||||||
|
{ id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const },
|
||||||
|
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PAGE_START_X = 0
|
||||||
|
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
||||||
|
|
||||||
|
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
||||||
|
let y = 0
|
||||||
|
return pages.map((page) => {
|
||||||
|
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
|
||||||
|
y += page.height
|
||||||
|
return geometry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) {
|
||||||
|
const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH
|
||||||
|
const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT
|
||||||
|
editor.setCameraOptions({
|
||||||
|
constraints: {
|
||||||
|
bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 },
|
||||||
|
padding: { x: 64, y: 64 },
|
||||||
|
origin: { x: 0.5, y: 0 },
|
||||||
|
initialZoom: 'fit-x-100',
|
||||||
|
baseZoom: 'default',
|
||||||
|
behavior: 'contain',
|
||||||
|
},
|
||||||
|
isLocked: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 domainIdForShape(shape: ExamCanvasTLShape): string {
|
||||||
|
const fromProps = shape.props.domainId
|
||||||
|
if (isUuid(fromProps)) return fromProps
|
||||||
|
const fromShapeId = stripShapePrefix(shape.id)
|
||||||
|
return isUuid(fromShapeId) ? fromShapeId : newDomainId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDomainIds(editor: Editor) {
|
||||||
|
const updates = editor.getCurrentPageShapes()
|
||||||
|
.filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type))
|
||||||
|
.filter((shape) => !isUuid(shape.props.domainId))
|
||||||
|
.map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } }))
|
||||||
|
if (updates.length) editor.updateShapes(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||||
|
const kind = shapeTypeToKind(shape.type)
|
||||||
|
if (!kind) return null
|
||||||
|
const s = shape as ExamCanvasTLShape
|
||||||
|
return {
|
||||||
|
id: domainIdForShape(s),
|
||||||
|
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 bringDomainShapesToFront(editor: Editor) {
|
||||||
|
const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id)
|
||||||
|
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||||
|
if (!models.length) return
|
||||||
|
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||||
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
|
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, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id },
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
||||||
|
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
|
||||||
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
|
if (!pages.length) return
|
||||||
|
const geometries = pageGeometryFromImages(pages)
|
||||||
|
editor.createShapes(geometries.map((geometry) => {
|
||||||
|
const page = pages[geometry.pageNumber - 1]
|
||||||
|
return {
|
||||||
|
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
|
||||||
|
type: PDF_PAGE_SHAPE_TYPE,
|
||||||
|
x: geometry.x,
|
||||||
|
y: geometry.y,
|
||||||
|
isLocked: true,
|
||||||
|
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
|
||||||
|
} as any
|
||||||
|
}))
|
||||||
|
// z-order is enforced by the caller via bringDomainShapesToFront
|
||||||
|
}
|
||||||
|
|
||||||
|
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', label: 'Q1 start', domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamTemplateSetupInner: React.FC = () => {
|
||||||
|
const { templateId } = useParams<{ templateId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const theme = useTheme()
|
||||||
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
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 [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
||||||
|
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!templateId) return
|
||||||
|
setLoading(true); setError(null); setConflict(null)
|
||||||
|
try {
|
||||||
|
const detail = await examRepository.getTemplate(templateId)
|
||||||
|
setTemplate(detail)
|
||||||
|
let pages: PdfPageImage[] = []
|
||||||
|
setPdfStatus('loading')
|
||||||
|
setPdfError(null)
|
||||||
|
try {
|
||||||
|
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||||
|
pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
|
||||||
|
const newPage = partialPages[partialPages.length - 1]
|
||||||
|
const allGeometries = pageGeometryFromImages(partialPages)
|
||||||
|
pageGeometriesRef.current = allGeometries
|
||||||
|
const ed = editorRef.current
|
||||||
|
if (ed) {
|
||||||
|
const geometry = allGeometries[partialPages.length - 1]
|
||||||
|
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
|
||||||
|
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
|
||||||
|
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
|
||||||
|
bringDomainShapesToFront(ed)
|
||||||
|
}
|
||||||
|
applyDocViewConstraints(ed, partialPages)
|
||||||
|
}
|
||||||
|
setPdfStatus('ready')
|
||||||
|
})
|
||||||
|
setPdfStatus(pages.length ? 'ready' : 'missing')
|
||||||
|
} catch (pdfErr) {
|
||||||
|
const pdfMsg = apiMessage(pdfErr).message
|
||||||
|
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
|
||||||
|
setPdfError(pdfMsg)
|
||||||
|
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
|
||||||
|
}
|
||||||
|
const geometries = pageGeometryFromImages(pages)
|
||||||
|
pageGeometriesRef.current = geometries
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (editor) {
|
||||||
|
syncPdfPages(editor, pages)
|
||||||
|
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
applyDocViewConstraints(editor, pages)
|
||||||
|
editor.resetZoom()
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}, [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 {
|
||||||
|
ensureDomainIds(editor)
|
||||||
|
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||||
|
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
|
||||||
|
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||||
|
setTemplate(saved)
|
||||||
|
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
|
||||||
|
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" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
|
||||||
|
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', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
|
||||||
|
{/* Top bar — single compact line */}
|
||||||
|
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
|
||||||
|
<Tooltip title="Back to exam marker">
|
||||||
|
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Divider orientation="vertical" flexItem />
|
||||||
|
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
||||||
|
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||||
|
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Body row */}
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Left tool sidebar */}
|
||||||
|
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
||||||
|
<Stack spacing={1}>{toolButtons}</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Canvas area */}
|
||||||
|
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
|
||||||
|
<Tldraw
|
||||||
|
shapeUtils={examCanvasShapeUtils as any}
|
||||||
|
tools={examCanvasTools as any}
|
||||||
|
hideUi
|
||||||
|
inferDarkMode={theme.palette.mode === 'dark'}
|
||||||
|
autoFocus
|
||||||
|
onMount={(editor) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||||
|
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||||
|
applyDocViewConstraints(editor, [])
|
||||||
|
editor.resetZoom()
|
||||||
|
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Guide toggle */}
|
||||||
|
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
||||||
|
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
||||||
|
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Guide panel — collapsible */}
|
||||||
|
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
|
||||||
|
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
||||||
|
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
||||||
|
const p = canvasShapePalette[kind]
|
||||||
|
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
|
||||||
|
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
||||||
|
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
{/* Conflict alert */}
|
||||||
|
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
||||||
|
<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,215 @@
|
|||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
|
||||||
|
import type { TLHandle } from '@tldraw/tldraw'
|
||||||
|
import { PAGE_WIDTH } from '../../../utils/exam-canvas/model'
|
||||||
|
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 ExamPdfPageTLShape = TLBaseBoxShape & {
|
||||||
|
type: typeof PDF_PAGE_SHAPE_TYPE
|
||||||
|
props: { w: number; h: number; src: string; pageNumber: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExamCanvasTLShape = TLBaseBoxShape & {
|
||||||
|
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
|
||||||
|
props: {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
label: string
|
||||||
|
kind: ExamCanvasShapeKind
|
||||||
|
maxMarks?: number
|
||||||
|
responseForm?: string
|
||||||
|
contextType?: string
|
||||||
|
questionId?: string | null
|
||||||
|
domainId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CanvasPaletteEntry = {
|
||||||
|
stroke: string
|
||||||
|
fill: string
|
||||||
|
darkStroke: string
|
||||||
|
darkFill: string
|
||||||
|
dash?: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
|
||||||
|
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' },
|
||||||
|
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' },
|
||||||
|
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' },
|
||||||
|
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' },
|
||||||
|
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' },
|
||||||
|
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' },
|
||||||
|
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' },
|
||||||
|
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const shapeCss = `
|
||||||
|
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
|
||||||
|
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
|
||||||
|
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
|
||||||
|
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||||
|
`
|
||||||
|
|
||||||
|
function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
||||||
|
const p = canvasShapePalette.boundary
|
||||||
|
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
||||||
|
<style>{shapeCss}</style>
|
||||||
|
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||||
|
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={p.dash} strokeLinecap="round" style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
||||||
|
</svg>
|
||||||
|
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
||||||
|
<span aria-hidden="true">{p.icon}</span>
|
||||||
|
{shape.props.label || p.label}
|
||||||
|
</span>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
|
const kind = shape.props.kind
|
||||||
|
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||||
|
const isBoundary = kind === 'boundary'
|
||||||
|
if (isBoundary) return renderBoundaryLine(shape)
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||||
|
<style>{shapeCss}</style>
|
||||||
|
<div
|
||||||
|
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
|
||||||
|
style={{
|
||||||
|
'--exam-light-stroke': p.stroke,
|
||||||
|
'--exam-light-fill': p.fill,
|
||||||
|
'--exam-dark-stroke': p.darkStroke,
|
||||||
|
'--exam-dark-fill': p.darkFill,
|
||||||
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||||
|
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
|
background: isBoundary ? 'transparent' : 'var(--exam-fill)', color: 'var(--exam-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 ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
aria-label={`${p.label}: ${p.role}`}
|
||||||
|
title={`${p.label}: ${p.role}`}
|
||||||
|
>
|
||||||
|
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span aria-hidden="true">{p.icon}</span>
|
||||||
|
{shape.props.label || p.label}
|
||||||
|
</span>
|
||||||
|
{!isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
||||||
|
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
||||||
|
</div>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||||
|
const p = canvasShapePalette[kind]
|
||||||
|
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }
|
||||||
|
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||||
|
|
||||||
|
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||||
|
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 component(shape: ExamPdfPageTLShape) {
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||||
|
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||||
|
}
|
||||||
|
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
|
||||||
|
static override type = SHAPE_TYPES.boundary
|
||||||
|
static override props = sharedProps
|
||||||
|
|
||||||
|
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
|
||||||
|
override canEdit() { return false }
|
||||||
|
override canResize() { return false }
|
||||||
|
override canBind() { return false }
|
||||||
|
override hideResizeHandles() { return true }
|
||||||
|
override hideRotateHandle() { return true }
|
||||||
|
override hideSelectionBoundsBg() { return true }
|
||||||
|
|
||||||
|
private pageSpanForY(y: number) {
|
||||||
|
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
|
||||||
|
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
|
||||||
|
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
|
||||||
|
if (!best) return page
|
||||||
|
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
|
||||||
|
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
|
||||||
|
return pageDy < bestDy ? page : best
|
||||||
|
}, null)
|
||||||
|
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
|
||||||
|
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
|
||||||
|
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override getGeometry(shape: ExamCanvasTLShape) {
|
||||||
|
const y = shape.props.h / 2
|
||||||
|
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
|
||||||
|
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
|
||||||
|
}
|
||||||
|
|
||||||
|
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
|
||||||
|
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
|
||||||
|
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
|
||||||
|
return this.normalize({ ...current, x: initial.x })
|
||||||
|
}
|
||||||
|
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
|
||||||
|
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
|
||||||
|
}
|
||||||
|
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
|
||||||
|
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
|
||||||
|
}
|
||||||
|
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||||
|
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(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 isPdfPageShape(type: string): boolean {
|
||||||
|
return type === PDF_PAGE_SHAPE_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
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,47 @@
|
|||||||
|
import * as pdfjsLib from "pdfjs-dist"
|
||||||
|
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url"
|
||||||
|
|
||||||
|
import { PAGE_WIDTH } from "../../../utils/exam-canvas/model"
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc
|
||||||
|
|
||||||
|
export interface PdfPageImage {
|
||||||
|
pageNumber: number
|
||||||
|
src: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPdfPageImages(
|
||||||
|
pdfBytes: ArrayBuffer,
|
||||||
|
targetWidth = PAGE_WIDTH,
|
||||||
|
onPageReady?: (pages: PdfPageImage[]) => void,
|
||||||
|
): Promise<PdfPageImage[]> {
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
|
||||||
|
const pages: PdfPageImage[] = []
|
||||||
|
// Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory
|
||||||
|
// for a typical 36-page exam paper.
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdf.getPage(pageNumber)
|
||||||
|
const baseViewport = page.getViewport({ scale: 1 })
|
||||||
|
const scale = targetWidth / baseViewport.width
|
||||||
|
const viewport = page.getViewport({ scale })
|
||||||
|
canvas.width = Math.ceil(viewport.width)
|
||||||
|
canvas.height = Math.ceil(viewport.height)
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) throw new Error("Unable to create PDF render canvas")
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
await page.render({ canvasContext: context, viewport }).promise
|
||||||
|
pages.push({
|
||||||
|
pageNumber,
|
||||||
|
src: canvas.toDataURL("image/png"),
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
})
|
||||||
|
onPageReady?.([...pages])
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
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';
|
||||||
|
import { ResultsWidget } from '../exam';
|
||||||
|
|
||||||
|
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}/database/timetable/classes/${classId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (clsRes.id) {
|
||||||
|
setCls({
|
||||||
|
...clsRes,
|
||||||
|
class_code: clsRes.class_code || clsRes.code,
|
||||||
|
year_group: clsRes.year_group || clsRes.school_year,
|
||||||
|
teachers: clsRes.teachers || [],
|
||||||
|
students: clsRes.students || [],
|
||||||
|
enrollment_requests: clsRes.enrollment_requests || [],
|
||||||
|
student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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(`/database/timetable/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(`/database/timetable/classes/${classId}/students/${studentId}`);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
||||||
|
setActionError(null);
|
||||||
|
const res = await apiPatch(`/database/timetable/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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResultsWidget classId={cls.id} className={cls.name} />
|
||||||
|
|
||||||
|
{/* 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import 'tldraw/tldraw.css';
|
|
||||||
import { CCPdfEditor } from './CCPdfEditor';
|
|
||||||
import { CCPdfPicker } from './CCPdfPicker';
|
|
||||||
import { ExamPdfState } from './types';
|
|
||||||
import './cc-exam-marker.css';
|
|
||||||
import { HEADER_HEIGHT } from '../../Layout';
|
|
||||||
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
|
|
||||||
|
|
||||||
export const CCExamMarker = () => {
|
|
||||||
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
|
|
||||||
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
|
|
||||||
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [isPinned, setIsPinned] = useState(false);
|
|
||||||
|
|
||||||
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
|
|
||||||
setView(newView);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextStudent = () => {
|
|
||||||
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
|
|
||||||
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
|
|
||||||
if (currentStudentIndex < totalStudents - 1) {
|
|
||||||
setCurrentStudentIndex(prev => prev + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviousStudent = () => {
|
|
||||||
if (currentStudentIndex > 0) {
|
|
||||||
setCurrentStudentIndex(prev => prev - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
top: `${HEADER_HEIGHT}px`,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
bgcolor: 'background.default',
|
|
||||||
color: 'text.primary',
|
|
||||||
}}>
|
|
||||||
{state.phase === 'pick' ? (
|
|
||||||
<CCPdfPicker
|
|
||||||
onOpenPdfs={(pdfs) =>
|
|
||||||
setState({
|
|
||||||
phase: 'edit',
|
|
||||||
examPaper: pdfs.examPaper,
|
|
||||||
markScheme: pdfs.markScheme,
|
|
||||||
studentResponses: pdfs.studentResponses,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
}}>
|
|
||||||
<CCPdfEditor
|
|
||||||
examPaper={state.examPaper}
|
|
||||||
markScheme={state.markScheme}
|
|
||||||
studentResponses={state.studentResponses}
|
|
||||||
currentView={view}
|
|
||||||
currentStudentIndex={currentStudentIndex}
|
|
||||||
onEditorMount={(editor) => {
|
|
||||||
if (!editor) return null;
|
|
||||||
const examMarkerProps = {
|
|
||||||
editor,
|
|
||||||
currentView: view,
|
|
||||||
onViewChange: handleViewChange,
|
|
||||||
currentStudentIndex,
|
|
||||||
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
|
|
||||||
onPreviousStudent: handlePreviousStudent,
|
|
||||||
onNextStudent: handleNextStudent,
|
|
||||||
getCurrentPdf: () => {
|
|
||||||
if (!editor) return null;
|
|
||||||
const currentPageId = editor.getCurrentPageId();
|
|
||||||
if (currentPageId.includes('exam-page')) {
|
|
||||||
return state.examPaper;
|
|
||||||
} else if (currentPageId.includes('mark-scheme-page')) {
|
|
||||||
return state.markScheme;
|
|
||||||
} else if (currentPageId.includes('student-response')) {
|
|
||||||
return state.studentResponses;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return <CCPanel
|
|
||||||
examMarkerProps={examMarkerProps}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
isPinned={isPinned}
|
|
||||||
onExpandedChange={setIsExpanded}
|
|
||||||
onPinnedChange={setIsPinned}
|
|
||||||
/>;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { PDFDocument } from 'pdf-lib';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Editor, exportToBlob } from '@tldraw/tldraw';
|
|
||||||
import { Button } from '@mui/material';
|
|
||||||
import { Pdf } from './types';
|
|
||||||
|
|
||||||
interface CCExportPdfButtonProps {
|
|
||||||
editor: Editor;
|
|
||||||
pdf: Pdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
|
|
||||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const exportPdf = async (
|
|
||||||
editor: Editor,
|
|
||||||
{ name, source, pages }: Pdf,
|
|
||||||
onProgress: (progress: number) => void
|
|
||||||
) => {
|
|
||||||
const totalThings = pages.length * 2 + 2;
|
|
||||||
let progressCount = 0;
|
|
||||||
const tickProgress = () => {
|
|
||||||
progressCount++;
|
|
||||||
onProgress(progressCount / totalThings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pdf = await PDFDocument.load(source);
|
|
||||||
tickProgress();
|
|
||||||
const pdfPages = pdf.getPages();
|
|
||||||
|
|
||||||
if (pdfPages.length !== pages.length) {
|
|
||||||
throw new Error('PDF page count mismatch');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
|
|
||||||
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
|
|
||||||
(id) => !pageShapeIds.has(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < pages.length; i++) {
|
|
||||||
const page = pages[i];
|
|
||||||
const pdfPage = pdfPages[i];
|
|
||||||
const {bounds} = page;
|
|
||||||
|
|
||||||
const shapesInBounds = allIds.filter((id) => {
|
|
||||||
const shapePageBounds = editor.getShapePageBounds(id);
|
|
||||||
if (!shapePageBounds) return false;
|
|
||||||
return shapePageBounds.collides(bounds);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shapesInBounds.length === 0) {
|
|
||||||
tickProgress();
|
|
||||||
tickProgress();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportedPng = await exportToBlob({
|
|
||||||
editor,
|
|
||||||
ids: allIds,
|
|
||||||
format: 'png',
|
|
||||||
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: pdfPage.getWidth(),
|
|
||||||
height: pdfPage.getHeight(),
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([await pdf.save()], { type: 'application/pdf' })
|
|
||||||
);
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className="CCExportPdfButton"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={async () => {
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
await exportPdf(editor, pdf, setExportProgress);
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 16,
|
|
||||||
right: 16,
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{exportProgress
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: 'Export PDF'}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
|
|
||||||
import { Tldraw } from '@tldraw/tldraw';
|
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
||||||
import { ExamPdfs } from './types';
|
|
||||||
import { AnnotationManager, AnnotationData } from './AnnotationManager';
|
|
||||||
import { logger } from '../../../debugConfig';
|
|
||||||
|
|
||||||
const PAGE_SPACING = 32; // Same spacing as the example
|
|
||||||
|
|
||||||
interface CCPdfEditorProps extends ExamPdfs {
|
|
||||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
|
||||||
currentStudentIndex: number;
|
|
||||||
onEditorMount: (editor: Editor) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CCPdfEditor({
|
|
||||||
examPaper,
|
|
||||||
markScheme,
|
|
||||||
studentResponses,
|
|
||||||
currentView,
|
|
||||||
currentStudentIndex,
|
|
||||||
onEditorMount,
|
|
||||||
}: CCPdfEditorProps) {
|
|
||||||
const [editor, setEditor] = useState<Editor | null>(null);
|
|
||||||
const [pagesInitialized, setPagesInitialized] = useState(false);
|
|
||||||
const annotationManager = useRef(new AnnotationManager());
|
|
||||||
|
|
||||||
const handleMount = useCallback((editor: Editor) => {
|
|
||||||
setEditor(editor);
|
|
||||||
onEditorMount(editor);
|
|
||||||
|
|
||||||
// Subscribe to shape changes
|
|
||||||
editor.on('change', () => {
|
|
||||||
const shapes = editor.getCurrentPageShapeIds();
|
|
||||||
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
|
|
||||||
totalShapes: shapes.size,
|
|
||||||
currentPage: editor.getCurrentPageId()
|
|
||||||
});
|
|
||||||
|
|
||||||
shapes.forEach(shapeId => {
|
|
||||||
const shape = editor.getShape(shapeId);
|
|
||||||
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
|
|
||||||
const bounds = editor.getShapePageBounds(shapeId);
|
|
||||||
if (bounds) {
|
|
||||||
const currentPageId = editor.getCurrentPageId();
|
|
||||||
let annotationData: AnnotationData;
|
|
||||||
|
|
||||||
if (currentPageId.includes('student-response')) {
|
|
||||||
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
|
|
||||||
|
|
||||||
// Find which page this annotation belongs to by checking collision with page bounds
|
|
||||||
const pageShapes = Array.from(shapes).filter(id => {
|
|
||||||
const s = editor.getShape(id);
|
|
||||||
return s?.isLocked; // Locked shapes are our PDF pages
|
|
||||||
});
|
|
||||||
|
|
||||||
let pageIndex = -1; // Default to -1 if no collision found
|
|
||||||
for (let i = 0; i < pageShapes.length; i++) {
|
|
||||||
const pageShape = editor.getShape(pageShapes[i]);
|
|
||||||
if (!pageShape) continue;
|
|
||||||
|
|
||||||
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
|
|
||||||
if (!pageBounds) continue;
|
|
||||||
|
|
||||||
// Check if the annotation's center point is within the page bounds
|
|
||||||
const annotationCenter = {
|
|
||||||
x: bounds.x + bounds.width / 2,
|
|
||||||
y: bounds.y + bounds.height / 2
|
|
||||||
};
|
|
||||||
|
|
||||||
if (annotationCenter.x >= pageBounds.x &&
|
|
||||||
annotationCenter.x <= pageBounds.x + pageBounds.width &&
|
|
||||||
annotationCenter.y >= pageBounds.y &&
|
|
||||||
annotationCenter.y <= pageBounds.y + pageBounds.height) {
|
|
||||||
pageIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '📏 Calculated page index', {
|
|
||||||
shapeId,
|
|
||||||
shapeBounds: bounds,
|
|
||||||
pageIndex,
|
|
||||||
studentIndex
|
|
||||||
});
|
|
||||||
|
|
||||||
annotationData = {
|
|
||||||
studentIndex,
|
|
||||||
pageIndex,
|
|
||||||
shapeId,
|
|
||||||
bounds: {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// For exam/mark scheme, use current page type as index
|
|
||||||
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
|
|
||||||
annotationData = {
|
|
||||||
pageIndex,
|
|
||||||
shapeId,
|
|
||||||
bounds: {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
|
|
||||||
shapeId,
|
|
||||||
annotationData,
|
|
||||||
currentPage: currentPageId
|
|
||||||
});
|
|
||||||
|
|
||||||
annotationManager.current.addAnnotation(shapeId, annotationData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [onEditorMount]);
|
|
||||||
|
|
||||||
// Initial setup effect - runs only once when editor is mounted
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || pagesInitialized) return;
|
|
||||||
|
|
||||||
const setupExamAndMarkScheme = async () => {
|
|
||||||
const examPageId = 'page:exam-page' as TLPageId;
|
|
||||||
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
|
|
||||||
|
|
||||||
// Calculate vertical layout for exam pages
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
const examPages = examPaper.pages.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = top;
|
|
||||||
top += height + PAGE_SPACING;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
return { ...page, top: currentTop, width, height };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Center pages horizontally
|
|
||||||
examPages.forEach(page => {
|
|
||||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create exam paper page
|
|
||||||
editor.createPage({
|
|
||||||
id: examPageId,
|
|
||||||
name: 'Exam Paper',
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(examPageId);
|
|
||||||
|
|
||||||
// Create assets and shapes for exam pages
|
|
||||||
examPages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Similar process for mark scheme pages
|
|
||||||
let markSchemeTop = 0;
|
|
||||||
const markSchemePages = markScheme.pages.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = markSchemeTop;
|
|
||||||
markSchemeTop += height + PAGE_SPACING;
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create mark scheme page
|
|
||||||
editor.createPage({
|
|
||||||
id: markSchemePageId,
|
|
||||||
name: 'Mark Scheme',
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(markSchemePageId);
|
|
||||||
|
|
||||||
// Create assets and shapes for mark scheme pages
|
|
||||||
markSchemePages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Go back to exam page
|
|
||||||
editor.setCurrentPage(examPageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupStudentResponses = async () => {
|
|
||||||
const pagesPerStudent = examPaper.pages.length;
|
|
||||||
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
|
|
||||||
|
|
||||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
|
||||||
const startPage = studentIndex * pagesPerStudent;
|
|
||||||
const endPage = startPage + pagesPerStudent;
|
|
||||||
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
|
|
||||||
|
|
||||||
// Calculate vertical layout
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
const studentPages = studentResponses.pages
|
|
||||||
.slice(startPage, endPage)
|
|
||||||
.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = top;
|
|
||||||
top += height + PAGE_SPACING;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
return { ...page, top: currentTop, width, height };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Center pages horizontally
|
|
||||||
studentPages.forEach(page => {
|
|
||||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create page for this student
|
|
||||||
editor.createPage({
|
|
||||||
id: studentPageId,
|
|
||||||
name: `Student ${studentIndex + 1}`,
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(studentPageId);
|
|
||||||
|
|
||||||
// Create assets and shapes
|
|
||||||
studentPages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial setup of all pages
|
|
||||||
const setup = async () => {
|
|
||||||
await setupExamAndMarkScheme();
|
|
||||||
await setupStudentResponses();
|
|
||||||
setPagesInitialized(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
setup();
|
|
||||||
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
|
|
||||||
|
|
||||||
// Effect to handle view changes and navigation
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || !pagesInitialized) return;
|
|
||||||
|
|
||||||
// Switch to appropriate page based on current view
|
|
||||||
const targetPageId = currentView === 'exam-and-markscheme'
|
|
||||||
? ('page:exam-page' as TLPageId)
|
|
||||||
: (`page:student-response-${currentStudentIndex}` as TLPageId);
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '🔄 Switching view', {
|
|
||||||
currentView,
|
|
||||||
currentStudentIndex,
|
|
||||||
targetPageId
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.setCurrentPage(targetPageId);
|
|
||||||
|
|
||||||
// Update camera constraints for current page
|
|
||||||
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
|
|
||||||
(acc: TLBox | null, shapeId) => {
|
|
||||||
const bounds = editor.getShapePageBounds(shapeId);
|
|
||||||
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
|
|
||||||
},
|
|
||||||
null as TLBox | null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentPageBounds) {
|
|
||||||
const isMobile = editor.getViewportScreenBounds().width < 840;
|
|
||||||
editor.setCameraOptions({
|
|
||||||
constraints: {
|
|
||||||
bounds: currentPageBounds,
|
|
||||||
padding: { x: isMobile ? 16 : 164, y: 64 },
|
|
||||||
origin: { x: 0.5, y: 0 },
|
|
||||||
initialZoom: 'fit-x-100',
|
|
||||||
baseZoom: 'default',
|
|
||||||
behavior: 'contain',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.setCamera(editor.getCamera(), { reset: true });
|
|
||||||
}
|
|
||||||
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
|
|
||||||
|
|
||||||
// Expose annotationManager to parent through onEditorMount
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor) {
|
|
||||||
onEditorMount(editor);
|
|
||||||
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
|
|
||||||
editor.annotationManager = annotationManager.current;
|
|
||||||
}
|
|
||||||
}, [editor, onEditorMount]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
||||||
<Tldraw
|
|
||||||
onMount={handleMount}
|
|
||||||
components={{
|
|
||||||
InFrontOfTheCanvas: () => onEditorMount(editor!)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
|
||||||
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
|
|
||||||
import { ExamPdfs, Pdf, PdfPage } from './types';
|
|
||||||
|
|
||||||
interface CCPdfPickerProps {
|
|
||||||
onOpenPdfs: (pdfs: ExamPdfs) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageSpacing = 32;
|
|
||||||
|
|
||||||
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
|
|
||||||
|
|
||||||
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
|
|
||||||
const PdfJS = await import('pdfjs-dist');
|
|
||||||
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
|
|
||||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
|
||||||
import.meta.url
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
const pdf = await PdfJS.getDocument(source.slice()).promise;
|
|
||||||
const pages: PdfPage[] = [];
|
|
||||||
const canvas = window.document.createElement('canvas');
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) throw new Error('Failed to create canvas context');
|
|
||||||
|
|
||||||
const visualScale = 1.5;
|
|
||||||
const scale = window.devicePixelRatio;
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
|
|
||||||
for (let i = 1; i <= pdf.numPages; i++) {
|
|
||||||
const page = await pdf.getPage(i);
|
|
||||||
const viewport = page.getViewport({ scale: scale * visualScale });
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
|
|
||||||
const renderContext = {
|
|
||||||
canvasContext: context,
|
|
||||||
viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.render(renderContext).promise;
|
|
||||||
const width = viewport.width / scale;
|
|
||||||
const height = viewport.height / scale;
|
|
||||||
|
|
||||||
pages.push({
|
|
||||||
src: canvas.toDataURL(),
|
|
||||||
bounds: new TLBox(0, top, width, height),
|
|
||||||
assetId: AssetRecordType.createId(),
|
|
||||||
shapeId: createShapeId(),
|
|
||||||
});
|
|
||||||
|
|
||||||
top += height + pageSpacing;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.width = 0;
|
|
||||||
canvas.height = 0;
|
|
||||||
|
|
||||||
for (const page of pages) {
|
|
||||||
page.bounds.x = (widest - page.bounds.width) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
pages,
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const pdf = await loadPdf(file.name, await file.arrayBuffer());
|
|
||||||
|
|
||||||
// Validate student responses page count
|
|
||||||
if (type === 'studentResponses' && selectedPdfs.examPaper) {
|
|
||||||
const examPageCount = selectedPdfs.examPaper.pages.length;
|
|
||||||
if (pdf.pages.length % examPageCount !== 0) {
|
|
||||||
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading PDF:', error);
|
|
||||||
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFileInput = (type: keyof ExamPdfs) => {
|
|
||||||
const input = window.document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = 'application/pdf';
|
|
||||||
input.addEventListener('change', async (e) => {
|
|
||||||
const fileList = (e.target as HTMLInputElement).files;
|
|
||||||
if (!fileList || fileList.length === 0) return;
|
|
||||||
await handleFileSelect(type, fileList[0]);
|
|
||||||
});
|
|
||||||
input.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const allPdfsSelected = () => {
|
|
||||||
return (
|
|
||||||
selectedPdfs.examPaper &&
|
|
||||||
selectedPdfs.markScheme &&
|
|
||||||
selectedPdfs.studentResponses
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Box className="CCPdfPicker" sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Typography>Loading...</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="CCPdfPicker" sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Stack
|
|
||||||
spacing={4}
|
|
||||||
alignItems="center"
|
|
||||||
sx={{
|
|
||||||
maxWidth: '800px',
|
|
||||||
width: '100%',
|
|
||||||
p: 3
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5">Select PDF Files</Typography>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('examPaper')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('markScheme')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('studentResponses')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{allPdfsSelected() && (
|
|
||||||
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
.CCExamMarker {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfPicker {
|
|
||||||
position: absolute;
|
|
||||||
inset: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfBgRenderer {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfBgRenderer img {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .PageOverlayScreen-screen {
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
fill: var(--color-background);
|
|
||||||
fill-opacity: 0.8;
|
|
||||||
stroke: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .PageOverlayScreen-outline {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
box-shadow: var(--shadow-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCExportPdfButton {
|
|
||||||
font: inherit;
|
|
||||||
background: var(--color-primary);
|
|
||||||
border: none;
|
|
||||||
color: var(--color-selected-contrast);
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 6px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
pointer-events: all;
|
|
||||||
z-index: var(--layer-panels);
|
|
||||||
border: 2px solid var(--color-background);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCExportPdfButton:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
|
|
||||||
|
|
||||||
export interface PdfPage {
|
|
||||||
src: string;
|
|
||||||
bounds: Box;
|
|
||||||
assetId: TLAssetId;
|
|
||||||
shapeId: TLShapeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Pdf {
|
|
||||||
name: string;
|
|
||||||
pages: PdfPage[];
|
|
||||||
source: string | ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamPdfs {
|
|
||||||
examPaper: Pdf;
|
|
||||||
markScheme: Pdf;
|
|
||||||
studentResponses: Pdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExamPdfState =
|
|
||||||
| {
|
|
||||||
phase: 'pick';
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
phase: 'edit';
|
|
||||||
examPaper: Pdf;
|
|
||||||
markScheme: Pdf;
|
|
||||||
studentResponses: Pdf;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface StudentResponse {
|
|
||||||
studentId: string;
|
|
||||||
pageStart: number;
|
|
||||||
pageEnd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamMetadata {
|
|
||||||
totalPages: number;
|
|
||||||
pagesPerStudent: number;
|
|
||||||
totalStudents: number;
|
|
||||||
studentResponses: StudentResponse[];
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useMemo } from 'react';
|
import { useEffect, useRef, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Tldraw,
|
Tldraw,
|
||||||
@@ -26,39 +26,117 @@ import { allBindingUtils } from '../../utils/tldraw/bindings';
|
|||||||
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
|
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
|
||||||
// Layout
|
// Layout
|
||||||
import { HEADER_HEIGHT } from '../../pages/Layout';
|
import { HEADER_HEIGHT } from '../../pages/Layout';
|
||||||
|
import { TLSYNC_URL } from '../../config/apiConfig';
|
||||||
// Styles
|
// Styles
|
||||||
import '../../utils/tldraw/tldraw.css';
|
import '../../utils/tldraw/tldraw.css';
|
||||||
// App debug
|
// App debug
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http')
|
const SYNC_WORKER_URL = TLSYNC_URL;
|
||||||
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
|
|
||||||
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`;
|
|
||||||
|
|
||||||
export default function TldrawMultiUser() {
|
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a short-lived TLSync token from the API using the current Supabase session.
|
||||||
|
* Returns { token, error } — exactly one will be non-null.
|
||||||
|
*/
|
||||||
|
function useTlsyncToken(accessToken: string | null) {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
logger.debug('multiplayer-page', '🔑 Fetching TLSync token from API');
|
||||||
|
fetch(`${apiBase}/api/tlsync/token`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`Token request failed: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
logger.debug('multiplayer-page', '✅ TLSync token received', { expiresIn: data.expires_in });
|
||||||
|
setToken(data.token);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
logger.error('multiplayer-page', '❌ Failed to fetch TLSync token', { err: err?.message });
|
||||||
|
setError(err?.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
return { token, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading / error overlay shown while the TLSync token is being fetched.
|
||||||
|
*/
|
||||||
|
function TlsyncStatusOverlay({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
top: `${HEADER_HEIGHT}px`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner component that owns the useSync hook.
|
||||||
|
* Rendered only once a valid TLSync token has been fetched from the API.
|
||||||
|
*/
|
||||||
|
function TldrawCanvas({
|
||||||
|
tlsyncToken,
|
||||||
|
roomId,
|
||||||
|
}: {
|
||||||
|
tlsyncToken: string;
|
||||||
|
roomId: string;
|
||||||
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
|
|
||||||
const {
|
const {
|
||||||
tldrawPreferences,
|
tldrawPreferences,
|
||||||
setTldrawPreferences,
|
setTldrawPreferences,
|
||||||
initializePreferences,
|
initializePreferences,
|
||||||
presentationMode
|
presentationMode,
|
||||||
} = useTLDraw();
|
} = useTLDraw();
|
||||||
const navigate = useNavigate();
|
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
|
|
||||||
// Get room ID from URL params
|
const userInfo = useMemo(
|
||||||
const roomId = searchParams.get('room') || 'multiplayer';
|
() => ({
|
||||||
|
|
||||||
// Memoize user information to ensure consistency
|
|
||||||
const userInfo = useMemo(() => ({
|
|
||||||
id: user?.id ?? '',
|
id: user?.id ?? '',
|
||||||
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
|
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
|
||||||
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`
|
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`,
|
||||||
}), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]);
|
}),
|
||||||
|
[user?.id, user?.display_name, user?.email, tldrawPreferences?.color],
|
||||||
|
);
|
||||||
|
|
||||||
// Create editor user with memoization
|
|
||||||
const editorUser = useTldrawUser({
|
const editorUser = useTldrawUser({
|
||||||
userPreferences: {
|
userPreferences: {
|
||||||
id: userInfo.id,
|
id: userInfo.id,
|
||||||
@@ -67,18 +145,23 @@ export default function TldrawMultiUser() {
|
|||||||
locale: tldrawPreferences?.locale,
|
locale: tldrawPreferences?.locale,
|
||||||
colorScheme: tldrawPreferences?.colorScheme,
|
colorScheme: tldrawPreferences?.colorScheme,
|
||||||
animationSpeed: tldrawPreferences?.animationSpeed,
|
animationSpeed: tldrawPreferences?.animationSpeed,
|
||||||
isSnapMode: tldrawPreferences?.isSnapMode
|
isSnapMode: tldrawPreferences?.isSnapMode,
|
||||||
},
|
},
|
||||||
setUserPreferences: setTldrawPreferences
|
setUserPreferences: setTldrawPreferences,
|
||||||
});
|
});
|
||||||
|
|
||||||
const connectionOptions = useMemo(() => createSyncConnectionOptions({
|
const connectionOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
createSyncConnectionOptions({
|
||||||
userId: userInfo.id,
|
userId: userInfo.id,
|
||||||
displayName: userInfo.name,
|
displayName: userInfo.name,
|
||||||
color: userInfo.color,
|
color: userInfo.color,
|
||||||
roomId,
|
roomId,
|
||||||
baseUrl: SYNC_WORKER_URL
|
baseUrl: SYNC_WORKER_URL,
|
||||||
}), [userInfo, roomId]);
|
token: tlsyncToken,
|
||||||
|
}),
|
||||||
|
[userInfo, roomId, tlsyncToken],
|
||||||
|
);
|
||||||
|
|
||||||
const store = useSync({
|
const store = useSync({
|
||||||
...connectionOptions,
|
...connectionOptions,
|
||||||
@@ -88,19 +171,17 @@ export default function TldrawMultiUser() {
|
|||||||
userInfo: {
|
userInfo: {
|
||||||
id: userInfo.id,
|
id: userInfo.id,
|
||||||
name: userInfo.name,
|
name: userInfo.name,
|
||||||
color: userInfo.color
|
color: userInfo.color,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log connection status changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
|
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
|
||||||
status: store.status,
|
status: store.status,
|
||||||
connectionOptions
|
roomId: connectionOptions.roomId,
|
||||||
});
|
});
|
||||||
}, [store.status, connectionOptions]);
|
}, [store.status, connectionOptions.roomId]);
|
||||||
|
|
||||||
// Effect for initializing preferences
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.id && !tldrawPreferences) {
|
if (user?.id && !tldrawPreferences) {
|
||||||
logger.info('multiplayer-page', '🔄 Initializing preferences');
|
logger.info('multiplayer-page', '🔄 Initializing preferences');
|
||||||
@@ -108,20 +189,11 @@ export default function TldrawMultiUser() {
|
|||||||
}
|
}
|
||||||
}, [user?.id, tldrawPreferences, initializePreferences]);
|
}, [user?.id, tldrawPreferences, initializePreferences]);
|
||||||
|
|
||||||
// Effect for redirecting if user is not authenticated
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) {
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
}, [user, navigate]);
|
|
||||||
|
|
||||||
// Effect for presentation mode
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (presentationMode && editorRef.current) {
|
if (presentationMode && editorRef.current) {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
const presentationService = new PresentationService(editor);
|
const presentationService = new PresentationService(editor);
|
||||||
const cleanup = presentationService.startPresentationMode();
|
const cleanup = presentationService.startPresentationMode();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
presentationService.stopPresentationMode();
|
presentationService.stopPresentationMode();
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -129,47 +201,24 @@ export default function TldrawMultiUser() {
|
|||||||
}
|
}
|
||||||
}, [presentationMode]);
|
}, [presentationMode]);
|
||||||
|
|
||||||
// Memoize UI overrides and components
|
|
||||||
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
|
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
|
||||||
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
|
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
|
||||||
|
|
||||||
// Render conditionally to avoid unnecessary rerenders
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
|
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
|
||||||
return (
|
return <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
top: `${HEADER_HEIGHT}px`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.1)'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
padding: '20px',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
|
||||||
}}>
|
|
||||||
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
top: `${HEADER_HEIGHT}px`,
|
top: `${HEADER_HEIGHT}px`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden',
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Tldraw
|
<Tldraw
|
||||||
user={editorUser}
|
user={editorUser}
|
||||||
store={store.store}
|
store={store.store}
|
||||||
@@ -198,3 +247,33 @@ export default function TldrawMultiUser() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function TldrawMultiUser() {
|
||||||
|
const { user, accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const roomId = searchParams.get('room') || 'multiplayer';
|
||||||
|
|
||||||
|
const { token: tlsyncToken, error: tlsyncError } = useTlsyncToken(accessToken);
|
||||||
|
|
||||||
|
// Redirect unauthenticated users
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tlsyncError) {
|
||||||
|
return <TlsyncStatusOverlay message={`Failed to connect to collaboration server: ${tlsyncError}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tlsyncToken) {
|
||||||
|
return <TlsyncStatusOverlay message="Connecting to collaboration server..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TldrawCanvas tlsyncToken={tlsyncToken} roomId={roomId} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ import {
|
|||||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
TLStore,
|
TLStore,
|
||||||
TLStoreWithStatus
|
createTLStore,
|
||||||
} from '@tldraw/tldraw';
|
} from '@tldraw/tldraw';
|
||||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useUser } from '../../contexts/UserContext';
|
import { useUser } from '../../contexts/UserContext';
|
||||||
// Tldraw services
|
// Tldraw services
|
||||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||||
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
|
|
||||||
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
|
||||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||||
// Tldraw utils
|
// Tldraw utils
|
||||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||||
@@ -34,9 +33,7 @@ import '../../utils/tldraw/tldraw.css';
|
|||||||
// App debug
|
// App debug
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
||||||
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
|
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||||
import { NodeData } from '../../types/graph-shape';
|
|
||||||
import { NavigationNode } from '../../types/navigation';
|
|
||||||
|
|
||||||
interface LoadingState {
|
interface LoadingState {
|
||||||
status: 'ready' | 'loading' | 'error';
|
status: 'ready' | 'loading' | 'error';
|
||||||
@@ -45,7 +42,9 @@ interface LoadingState {
|
|||||||
|
|
||||||
export default function SinglePlayerPage() {
|
export default function SinglePlayerPage() {
|
||||||
// Context hooks with initialization states
|
// Context hooks with initialization states
|
||||||
const { user, loading: userLoading } = useUser();
|
const { profile: user, loading: userLoading } = useUser();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const { context, setAuthInfo, switchContext } = useNavigationStore();
|
||||||
const {
|
const {
|
||||||
tldrawPreferences,
|
tldrawPreferences,
|
||||||
initializePreferences,
|
initializePreferences,
|
||||||
@@ -55,8 +54,6 @@ export default function SinglePlayerPage() {
|
|||||||
const routerNavigate = useNavigate();
|
const routerNavigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// Navigation store
|
|
||||||
const { context } = useNavigationStore();
|
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
@@ -67,9 +64,8 @@ export default function SinglePlayerPage() {
|
|||||||
status: 'ready',
|
status: 'ready',
|
||||||
error: ''
|
error: ''
|
||||||
});
|
});
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
const storeRef = useRef<TLStore | null>(null);
|
||||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
const [storeReady, setStoreReady] = useState(false);
|
||||||
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
|
|
||||||
|
|
||||||
// TLDraw user preferences
|
// TLDraw user preferences
|
||||||
const tldrawUser = useTldrawUser({
|
const tldrawUser = useTldrawUser({
|
||||||
@@ -85,247 +81,107 @@ export default function SinglePlayerPage() {
|
|||||||
setUserPreferences: setTldrawPreferences
|
setUserPreferences: setTldrawPreferences
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize store
|
const [canvasPhase, setCanvasPhase] = useState<
|
||||||
|
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
|
||||||
|
>('idle');
|
||||||
|
|
||||||
|
// Effect 1: Create the store ONCE per user session. Never recreate on node changes.
|
||||||
|
// Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping
|
||||||
|
// the store alive for the component lifetime avoids all disposal race conditions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditorReady) {
|
if (!user) return;
|
||||||
logger.debug('single-player-page', '⏳ Waiting for editor to be ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
setCanvasPhase('store-init');
|
||||||
logger.debug('single-player-page', '⏳ Waiting for user data');
|
const store = createTLStore({
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editorRef.current) {
|
|
||||||
logger.debug('single-player-page', '⏳ Waiting for editor ref');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('single-player-page', '🔄 Starting store initialization', {
|
|
||||||
isEditorReady,
|
|
||||||
hasUser: !!user,
|
|
||||||
userType: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
const initializeStoreAndSnapshot = async () => {
|
|
||||||
try {
|
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
|
||||||
|
|
||||||
// 1. Create store
|
|
||||||
logger.debug('single-player-page', '🔄 Creating TLStore');
|
|
||||||
const newStore = localStoreService.getStore({
|
|
||||||
schema: customSchema,
|
schema: customSchema,
|
||||||
shapeUtils: allShapeUtils,
|
shapeUtils: allShapeUtils,
|
||||||
bindingUtils: allBindingUtils
|
bindingUtils: allBindingUtils,
|
||||||
});
|
});
|
||||||
logger.debug('single-player-page', '✅ TLStore created');
|
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
|
||||||
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
|
|
||||||
// 2. Initialize snapshot service
|
const snapSvc = new NavigationSnapshotService(store, undefined);
|
||||||
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
if (accessToken) snapSvc.setAccessToken(accessToken);
|
||||||
snapshotServiceRef.current = snapshotService;
|
snapshotServiceRef.current = snapSvc;
|
||||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
|
||||||
|
|
||||||
// 3. Load initial snapshot if we have a node
|
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
if (context.node) {
|
const unsubscribe = store.listen(() => {
|
||||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
||||||
if (nodeStoragePath) {
|
if (debounce) clearTimeout(debounce);
|
||||||
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
debounce = setTimeout(async () => {
|
||||||
dbName: user.user_db_name,
|
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
node: context.node,
|
}, 2000);
|
||||||
node_storage_path: nodeStoragePath,
|
|
||||||
user_type: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
storeRef.current = store;
|
||||||
nodeStoragePath,
|
setStoreReady(true);
|
||||||
user.user_db_name,
|
setCanvasPhase('ready');
|
||||||
newStore,
|
|
||||||
setLoadingState,
|
|
||||||
undefined, // sharedStore
|
|
||||||
editorRef.current || undefined // editor
|
|
||||||
);
|
|
||||||
logger.debug('single-player-page', '✅ Snapshot loaded from database');
|
|
||||||
} else {
|
|
||||||
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
|
|
||||||
node: context.node
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Set up auto-save with debouncing (only after initial load is complete)
|
|
||||||
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let isAutoSaving = false;
|
|
||||||
|
|
||||||
newStore.listen(() => {
|
|
||||||
if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) {
|
|
||||||
// Skip if already saving
|
|
||||||
if (isAutoSaving) {
|
|
||||||
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing timeout
|
|
||||||
if (autoSaveTimeout) {
|
|
||||||
clearTimeout(autoSaveTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce auto-save to prevent excessive saves
|
|
||||||
autoSaveTimeout = setTimeout(async () => {
|
|
||||||
if (isAutoSaving) return; // Double-check
|
|
||||||
|
|
||||||
isAutoSaving = true;
|
|
||||||
try {
|
|
||||||
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
|
|
||||||
await snapshotServiceRef.current?.forceSaveCurrentNode();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('single-player-page', '❌ Auto-save failed', error);
|
|
||||||
} finally {
|
|
||||||
isAutoSaving = false;
|
|
||||||
}
|
|
||||||
}, 2000); // Increased to 2 seconds debounce
|
|
||||||
} else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) {
|
|
||||||
logger.debug('single-player-page', '⚠️ Skipping auto-save - no current node path set yet');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Update store state
|
|
||||||
setStore(newStore);
|
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
|
||||||
logger.info('single-player-page', '✅ Store initialization complete');
|
|
||||||
|
|
||||||
// 6. Handle cleanup
|
|
||||||
return () => {
|
return () => {
|
||||||
logger.debug('single-player-page', '🧹 Starting cleanup');
|
unsubscribe?.();
|
||||||
if (snapshotServiceRef.current) {
|
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
logger.error('single-player-page', '❌ Final save failed', error);
|
|
||||||
});
|
|
||||||
snapshotServiceRef.current.clearCurrentNode();
|
|
||||||
snapshotServiceRef.current = null;
|
snapshotServiceRef.current = null;
|
||||||
}
|
setStoreReady(false);
|
||||||
newStore.dispose();
|
setCanvasPhase('idle');
|
||||||
setStore(undefined);
|
store.dispose();
|
||||||
logger.debug('single-player-page', '🧹 Cleanup complete');
|
storeRef.current = null;
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
|
|
||||||
logger.error('single-player-page', '❌ Store initialization failed', error);
|
|
||||||
setLoadingState({ status: 'error', error: errorMessage });
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
|
||||||
|
|
||||||
initializeStoreAndSnapshot();
|
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
|
||||||
}, [isEditorReady, user, context.node]);
|
|
||||||
|
|
||||||
// Handle initial node placement
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const placeInitialNode = async () => {
|
if (!user || !storeRef.current) return;
|
||||||
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
|
|
||||||
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
|
|
||||||
hasNode: !!context.node,
|
|
||||||
hasEditor: !!editorRef.current,
|
|
||||||
hasStore: !!store,
|
|
||||||
isInitialLoad
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: Log the actual node structure
|
const store = storeRef.current;
|
||||||
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
|
let cancelled = false;
|
||||||
node: context.node,
|
|
||||||
nodeKeys: Object.keys(context.node),
|
|
||||||
hasId: !!context.node.id,
|
|
||||||
hasStoragePath: !!context.node.node_storage_path,
|
|
||||||
hasData: !!context.node.data,
|
|
||||||
dataKeys: context.node.data ? Object.keys(context.node.data) : null
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate that the node has required properties
|
const loadNodeSnapshot = async () => {
|
||||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
if (!context.node?.node_storage_path) {
|
||||||
if (!context.node.id || !nodeStoragePath) {
|
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
|
||||||
logger.error('single-player-page', '❌ Node missing required properties', {
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
nodeId: context.node.id,
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
hasStoragePath: !!nodeStoragePath,
|
setCanvasPhase('ready');
|
||||||
node: context.node
|
|
||||||
});
|
|
||||||
setLoadingState({
|
|
||||||
status: 'error',
|
|
||||||
error: 'Node is missing required information'
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCanvasPhase('snapshot-loading');
|
||||||
try {
|
try {
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
|
context.node.node_storage_path,
|
||||||
// Center the node
|
accessToken || '',
|
||||||
const nodeData = await loadNodeData(context.node);
|
store,
|
||||||
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
|
setLoadingState,
|
||||||
|
undefined,
|
||||||
setIsInitialLoad(false);
|
editorRef.current || undefined
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
);
|
||||||
} catch (error) {
|
if (!cancelled) {
|
||||||
logger.error('single-player-page', '❌ Failed to place initial node', error);
|
// Repair store in case snapshot overwrote TLINSTANCE
|
||||||
setLoadingState({
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
status: 'error',
|
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
|
||||||
error: error instanceof Error ? error.message : 'Failed to place initial node'
|
setCanvasPhase('ready');
|
||||||
});
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
placeInitialNode();
|
loadNodeSnapshot();
|
||||||
}, [context.node, store, isInitialLoad]);
|
return () => { cancelled = true; };
|
||||||
|
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
|
||||||
|
|
||||||
// Handle navigation changes
|
// Handle navigation changes — save previous snapshot, load next one.
|
||||||
|
// No automatic node shape placement: the canvas shows only persisted state.
|
||||||
|
|
||||||
|
// Inject auth and trigger initial context when token is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleNodeChange = async () => {
|
if (user?.id && accessToken) {
|
||||||
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
|
setAuthInfo(accessToken, user.id);
|
||||||
return;
|
if (!context.node) {
|
||||||
|
switchContext({ main: 'profile', base: 'profile' }, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can safely assert these types because we've checked for null above
|
|
||||||
const editor = editorRef.current as Editor;
|
|
||||||
const snapshotService = snapshotServiceRef.current;
|
|
||||||
const currentNode = context.node;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
|
||||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
|
||||||
nodeId: currentNode.id,
|
|
||||||
node_storage_path: currentNode.node_storage_path,
|
|
||||||
isInitialLoad
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the previous node from navigation history
|
|
||||||
const previousNode = context.history.currentIndex > 0
|
|
||||||
? context.history.nodes[context.history.currentIndex - 1]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Handle navigation in snapshot service
|
|
||||||
await snapshotService.handleNavigationStart(previousNode, currentNode);
|
|
||||||
|
|
||||||
// Center the node on canvas
|
|
||||||
const nodeData = await loadNodeData(currentNode);
|
|
||||||
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
|
|
||||||
|
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('single-player-page', '❌ Failed to load node data', error);
|
|
||||||
setLoadingState({
|
|
||||||
status: 'error',
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to load node data'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
}, [user?.id, accessToken]);
|
||||||
|
|
||||||
handleNodeChange();
|
|
||||||
}, [context.node, context.history, store, isInitialLoad]);
|
|
||||||
|
|
||||||
// Initialize preferences when user is available
|
// Initialize preferences when user is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -451,7 +307,7 @@ export default function SinglePlayerPage() {
|
|||||||
const uiComponents = getUiComponents(presentationMode);
|
const uiComponents = getUiComponents(presentationMode);
|
||||||
|
|
||||||
// Show loading state if user context is still loading
|
// Show loading state if user context is still loading
|
||||||
if (userLoading) {
|
if (userLoading || !user) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -472,12 +328,9 @@ export default function SinglePlayerPage() {
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
top: `${HEADER_HEIGHT}px`,
|
top: `${HEADER_HEIGHT}px`,
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
}}>
|
||||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||||
{(loadingState.status === 'loading' || !store) && (
|
{(loadingState.status === 'loading' || !storeReady) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -505,9 +358,15 @@ export default function SinglePlayerPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
|
||||||
<Tldraw
|
<ErrorBoundary fallback={
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '1rem', padding: '2rem' }}>
|
||||||
|
<p style={{ fontSize: '1.1rem' }}>Canvas failed to load.</p>
|
||||||
|
<button onClick={() => window.location.reload()} style={{ padding: '0.5rem 1.25rem', cursor: 'pointer' }}>Reload</button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{storeReady && storeRef.current && <Tldraw
|
||||||
user={tldrawUser}
|
user={tldrawUser}
|
||||||
store={store}
|
store={storeRef.current}
|
||||||
tools={singlePlayerTools}
|
tools={singlePlayerTools}
|
||||||
shapeUtils={allShapeUtils}
|
shapeUtils={allShapeUtils}
|
||||||
bindingUtils={allBindingUtils}
|
bindingUtils={allBindingUtils}
|
||||||
@@ -537,106 +396,20 @@ export default function SinglePlayerPage() {
|
|||||||
// Update snapshot service with editor reference
|
// Update snapshot service with editor reference
|
||||||
if (snapshotServiceRef.current) {
|
if (snapshotServiceRef.current) {
|
||||||
snapshotServiceRef.current.setEditor(editor);
|
snapshotServiceRef.current.setEditor(editor);
|
||||||
|
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditorReady(true);
|
|
||||||
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
||||||
editorId: editor.store.id,
|
editorId: editor.store.id,
|
||||||
presentationMode,
|
presentationMode,
|
||||||
isEditorReady: true
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('single-player-page', '❌ Error in onMount', error);
|
logger.error('single-player-page', '❌ Error in onMount', error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>}
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to safely extract node_storage_path from different node structures
|
|
||||||
const getNodeStoragePath = (node: NavigationNode): string | null => {
|
|
||||||
// Try direct access first
|
|
||||||
if (node.node_storage_path) {
|
|
||||||
return node.node_storage_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try nested under data
|
|
||||||
if (node.data?.node_storage_path) {
|
|
||||||
return node.data.node_storage_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try other possible locations
|
|
||||||
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
|
|
||||||
return node.data.storage_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
|
||||||
// Validate the node parameter
|
|
||||||
if (!node) {
|
|
||||||
throw new Error('Node parameter is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node.id) {
|
|
||||||
throw new Error('Node must have an ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeStoragePath = getNodeStoragePath(node);
|
|
||||||
if (!nodeStoragePath) {
|
|
||||||
throw new Error(`Node ${node.id} is missing node_storage_path`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
nodeLabel: node.label,
|
|
||||||
nodeStoragePath: nodeStoragePath
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Always fetch fresh data
|
|
||||||
// Create a temporary node object with the correct structure for the service
|
|
||||||
const normalizedNode = {
|
|
||||||
...node,
|
|
||||||
node_storage_path: nodeStoragePath
|
|
||||||
};
|
|
||||||
|
|
||||||
const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode);
|
|
||||||
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
|
|
||||||
|
|
||||||
if (!fetchedData?.node_data) {
|
|
||||||
throw new Error('Failed to fetch node data');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Process the data into the correct shape
|
|
||||||
const theme = getThemeFromLabel(node.type);
|
|
||||||
return {
|
|
||||||
...fetchedData.node_data,
|
|
||||||
title: String(fetchedData.node_data.title || node.label || ''),
|
|
||||||
w: 500,
|
|
||||||
h: 350,
|
|
||||||
state: {
|
|
||||||
parentId: null,
|
|
||||||
isPageChild: true,
|
|
||||||
hasChildren: null,
|
|
||||||
bindings: null
|
|
||||||
},
|
|
||||||
headerColor: theme.headerColor,
|
|
||||||
backgroundColor: theme.backgroundColor,
|
|
||||||
isLocked: false,
|
|
||||||
__primarylabel__: node.type,
|
|
||||||
uuid_string: node.id,
|
|
||||||
node_storage_path: nodeStoragePath
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('single-player-page', '❌ Error in loadNodeData', {
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: node.type,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
Grid,
|
Grid,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -10,17 +12,28 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import { useUser } from '../../contexts/UserContext';
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
|
||||||
const DashboardPage: React.FC = () => {
|
const DashboardPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user: authUser } = useAuth();
|
const { user: authUser, bootstrapData } = useAuth();
|
||||||
const { profile, loading } = useUser();
|
const { profile, loading } = useUser();
|
||||||
|
|
||||||
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
|
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
|
||||||
const emailAddress = profile?.email || authUser?.email || '';
|
const emailAddress = profile?.email || authUser?.email || '';
|
||||||
const userType = profile?.user_type || authUser?.user_type || '';
|
const userType = profile?.user_type || authUser?.user_type || '';
|
||||||
|
|
||||||
|
const activeInstitute = bootstrapData?.active_institute;
|
||||||
|
const activeMembership = bootstrapData?.memberships?.find(
|
||||||
|
(m) => m.institute_id === activeInstitute?.id && m.is_active
|
||||||
|
);
|
||||||
|
const schoolName = activeMembership?.institute?.name;
|
||||||
|
const onboarding = bootstrapData?.onboarding;
|
||||||
|
const calendarOk = bootstrapData?.calendar_status?.available && !bootstrapData?.calendar_status?.needs_setup;
|
||||||
|
const timetableOk = bootstrapData?.timetable_status?.available && !bootstrapData?.timetable_status?.needs_setup;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
<Stack spacing={6}>
|
<Stack spacing={6}>
|
||||||
@@ -72,6 +85,12 @@ const DashboardPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Open workspace
|
Open workspace
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate('/exam-marker')}
|
||||||
|
>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => navigate('/calendar')}
|
onClick={() => navigate('/calendar')}
|
||||||
@@ -88,6 +107,79 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
{/* Bootstrap Status Section */}
|
||||||
|
{bootstrapData && (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="h5" component="h2">
|
||||||
|
Your school
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Paper elevation={2} sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Institute
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="body1" fontWeight={600}>
|
||||||
|
{schoolName || 'No school assigned'}
|
||||||
|
</Typography>
|
||||||
|
{activeInstitute?.membership_role && (
|
||||||
|
<Chip
|
||||||
|
label={activeInstitute.membership_role.replace(/_/g, ' ')}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Status: {bootstrapData.school_status}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Paper elevation={2} sx={{ p: 3, height: '100%' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
System status
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{calendarOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||||
|
<Typography variant="body2">
|
||||||
|
Calendar: {calendarOk ? 'Ready' : 'Needs setup'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{timetableOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||||
|
<Typography variant="body2">
|
||||||
|
Timetable: {timetableOk ? 'Ready' : 'Needs setup'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{bootstrapData?.graph_status?.available ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||||
|
<Typography variant="body2">
|
||||||
|
Graph projection: {bootstrapData?.graph_status?.projection_state || 'unknown'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{onboarding && onboarding.next_step !== 'complete' && (
|
||||||
|
<Alert severity="info" sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{onboarding.message}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||||
|
Next step: {onboarding.next_step.replace(/_/g, ' ')}
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { TLUserPreferences } from '@tldraw/tldraw';
|
|||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import { storageService, StorageKeys } from './localStorageService';
|
import { storageService, StorageKeys } from './localStorageService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { DatabaseNameService } from '../graph/databaseNameService';
|
|
||||||
|
|
||||||
export interface CCUser {
|
export interface CCUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,8 +10,7 @@ export interface CCUser {
|
|||||||
user_type: string;
|
user_type: string;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
user_db_name: string;
|
school_id?: string | null;
|
||||||
school_db_name: string;
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -44,28 +42,13 @@ export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
|
|||||||
// Default to student if no user type specified
|
// Default to student if no user type specified
|
||||||
const userType = metadata.user_type || 'student';
|
const userType = metadata.user_type || 'student';
|
||||||
|
|
||||||
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
|
|
||||||
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
|
|
||||||
|
|
||||||
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(
|
|
||||||
userType,
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
const schoolDbName = metadata.school_db_name || metadata.worker_db_name || storedSchoolDb || '';
|
|
||||||
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName,
|
|
||||||
schoolDbName
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
user_type: userType,
|
user_type: userType,
|
||||||
username: username,
|
username,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
user_db_name: userDbName,
|
school_id: null,
|
||||||
school_db_name: schoolDbName,
|
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
updated_at: user.updated_at,
|
updated_at: user.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { RegistrationResponse } from '../../services/auth/authService';
|
|||||||
import { storageService, StorageKeys } from './localStorageService';
|
import { storageService, StorageKeys } from './localStorageService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { provisionUser } from '../provisioningService';
|
import { provisionUser } from '../provisioningService';
|
||||||
import { DatabaseNameService } from '../graph/databaseNameService';
|
|
||||||
|
|
||||||
const REGISTRATION_SERVICE = 'registration-service';
|
const REGISTRATION_SERVICE = 'registration-service';
|
||||||
|
|
||||||
@@ -87,14 +86,6 @@ export class RegistrationService {
|
|||||||
try {
|
try {
|
||||||
const provisioned = await provisionUser(ccUser.id, provisioningToken);
|
const provisioned = await provisionUser(ccUser.id, provisioningToken);
|
||||||
if (provisioned) {
|
if (provisioned) {
|
||||||
ccUser.user_db_name = provisioned.user_db_name;
|
|
||||||
if (provisioned.worker_db_name) {
|
|
||||||
ccUser.school_db_name = provisioned.worker_db_name;
|
|
||||||
}
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: ccUser.user_db_name,
|
|
||||||
schoolDbName: ccUser.school_db_name
|
|
||||||
});
|
|
||||||
logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
|
logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
|
||||||
userId: ccUser.id,
|
userId: ccUser.id,
|
||||||
userDbName: provisioned.user_db_name,
|
userDbName: provisioned.user_db_name,
|
||||||
@@ -110,11 +101,6 @@ export class RegistrationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
DatabaseNameService.rememberDatabaseNames({
|
|
||||||
userDbName: ccUser.user_db_name,
|
|
||||||
schoolDbName: ccUser.school_db_name
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: ccUser,
|
user: ccUser,
|
||||||
accessToken: authData.session?.access_token || null,
|
accessToken: authData.session?.access_token || null,
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
|
||||||
|
|
||||||
|
export interface BootstrapProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
user_type: string;
|
||||||
|
school_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapMembership {
|
||||||
|
institute_id: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
is_active: boolean;
|
||||||
|
institute: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
urn: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapActiveInstitute {
|
||||||
|
id: string | null;
|
||||||
|
source: string;
|
||||||
|
membership_role: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapPermissions {
|
||||||
|
platform_admin: boolean;
|
||||||
|
platform_super_admin: boolean;
|
||||||
|
can_create_school: boolean;
|
||||||
|
can_manage_school: boolean;
|
||||||
|
can_manage_calendar: boolean;
|
||||||
|
can_manage_timetable: boolean;
|
||||||
|
can_invite_staff: boolean;
|
||||||
|
can_manage_classes: boolean;
|
||||||
|
can_view_student_data: boolean;
|
||||||
|
can_use_canvas: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapOnboarding {
|
||||||
|
next_step: string;
|
||||||
|
required: string[];
|
||||||
|
optional: string[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapCalendarStatus {
|
||||||
|
available: boolean;
|
||||||
|
academic_year_count: number;
|
||||||
|
term_count: number;
|
||||||
|
current_academic_year_id: string | null;
|
||||||
|
needs_setup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapTimetableStatus {
|
||||||
|
available: boolean;
|
||||||
|
teacher_timetable_id: string | null;
|
||||||
|
slot_count: number;
|
||||||
|
needs_setup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapGraphStatus {
|
||||||
|
available: boolean;
|
||||||
|
user_db: string | null;
|
||||||
|
institute_db: string | null;
|
||||||
|
projection_state: string;
|
||||||
|
needs_rebuild: boolean;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapResponse {
|
||||||
|
profile: BootstrapProfile;
|
||||||
|
memberships: BootstrapMembership[];
|
||||||
|
active_institute: BootstrapActiveInstitute;
|
||||||
|
permissions: BootstrapPermissions;
|
||||||
|
school_status: string;
|
||||||
|
onboarding: BootstrapOnboarding;
|
||||||
|
calendar_status: BootstrapCalendarStatus;
|
||||||
|
timetable_status: BootstrapTimetableStatus;
|
||||||
|
graph_status: BootstrapGraphStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedBootstrap: BootstrapResponse | null = null;
|
||||||
|
let cacheTimestamp = 0;
|
||||||
|
const CACHE_TTL_MS = 60_000; // 60 second cache
|
||||||
|
|
||||||
|
export async function fetchBootstrapData(accessToken: string): Promise<BootstrapResponse | null> {
|
||||||
|
if (!accessToken) return null;
|
||||||
|
|
||||||
|
// Return cached data if fresh
|
||||||
|
if (cachedBootstrap && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
||||||
|
return cachedBootstrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/me/bootstrap`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn('bootstrap-service', `Bootstrap fetch failed: ${res.status}`);
|
||||||
|
return cachedBootstrap; // Return stale cache on error
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: BootstrapResponse = await res.json();
|
||||||
|
cachedBootstrap = data;
|
||||||
|
cacheTimestamp = Date.now();
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('bootstrap-service', 'Bootstrap fetch error', { err });
|
||||||
|
return cachedBootstrap; // Return stale cache on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateBootstrapCache(): void {
|
||||||
|
cachedBootstrap = null;
|
||||||
|
cacheTimestamp = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* examRepository — the SINGLE module that talks to the /api/exam backend (spec R2.1 seam).
|
||||||
|
*
|
||||||
|
* All exam-marker persistence flows through here so a later dual-write / offline cache can slot
|
||||||
|
* in without touching feature code. Mirrors the auth pattern of timetableService: take the
|
||||||
|
* Supabase session JWT and send it as a Bearer token; the API enforces RLS as the user.
|
||||||
|
*/
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { API_BASE } from '../../config/apiConfig';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
import { supabase } from '../../supabaseClient';
|
||||||
|
import type {
|
||||||
|
BatchQueueResponse,
|
||||||
|
BatchResultsResponse,
|
||||||
|
CreateBatchPayload,
|
||||||
|
CreateTemplatePayload,
|
||||||
|
ExamBoundary,
|
||||||
|
ExamQuestion,
|
||||||
|
ExamResponseArea,
|
||||||
|
ExamTemplate,
|
||||||
|
ExamTemplateDetail,
|
||||||
|
MarkingBatch,
|
||||||
|
MarkUpsertPayload,
|
||||||
|
Neo4jSyncResult,
|
||||||
|
PatchQuestionPayload,
|
||||||
|
SpecPoint,
|
||||||
|
TemplateReplacePayload,
|
||||||
|
UpdateTemplateMetaPayload,
|
||||||
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
|
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||||
|
|
||||||
|
async function authHeaders(): Promise<Record<string, string>> {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) {
|
||||||
|
throw new Error('No authentication token available');
|
||||||
|
}
|
||||||
|
return { Authorization: `Bearer ${session.access_token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function newUuid(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = Math.floor(Math.random() * 16);
|
||||||
|
const v = c === 'x' ? r : (r % 4) + 8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
|
||||||
|
return {
|
||||||
|
id: idMap?.get(q.id) ?? q.id,
|
||||||
|
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
|
||||||
|
label: q.label,
|
||||||
|
order: q.order,
|
||||||
|
max_marks: q.max_marks,
|
||||||
|
answer_type: q.answer_type,
|
||||||
|
mcq_options: q.mcq_options,
|
||||||
|
mark_scheme: q.mark_scheme ?? {},
|
||||||
|
is_container: q.is_container,
|
||||||
|
spec_ref: q.spec_ref,
|
||||||
|
bounds: q.bounds ?? null,
|
||||||
|
page: q.page ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
|
||||||
|
return {
|
||||||
|
id: duplicate ? newUuid() : r.id,
|
||||||
|
question_id: idMap?.get(r.question_id) ?? r.question_id,
|
||||||
|
page: r.page,
|
||||||
|
bounds: r.bounds,
|
||||||
|
kind: r.kind,
|
||||||
|
response_form: r.response_form,
|
||||||
|
context_type: r.context_type ?? null,
|
||||||
|
source: r.source,
|
||||||
|
confirmed: r.confirmed,
|
||||||
|
confidence: r.confidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
|
||||||
|
return {
|
||||||
|
id: duplicate ? newUuid() : b.id,
|
||||||
|
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
|
||||||
|
label: b.label,
|
||||||
|
page_index: b.page_index,
|
||||||
|
y: b.y,
|
||||||
|
bounds: b.bounds,
|
||||||
|
source: b.source,
|
||||||
|
confirmed: b.confirmed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceTemplate(
|
||||||
|
templateId: string,
|
||||||
|
detail: ExamTemplateDetail,
|
||||||
|
meta?: UpdateTemplateMetaPayload,
|
||||||
|
duplicateIds = false,
|
||||||
|
): Promise<ExamTemplateDetail> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
if (duplicateIds) {
|
||||||
|
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
|
||||||
|
}
|
||||||
|
const res = await axios.put<ExamTemplateDetail>(
|
||||||
|
`${EXAM_BASE}/templates/${templateId}`,
|
||||||
|
{
|
||||||
|
meta,
|
||||||
|
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
||||||
|
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
||||||
|
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const examRepository = {
|
||||||
|
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ templates: ExamTemplate[] }>(
|
||||||
|
`${EXAM_BASE}/templates`,
|
||||||
|
{ headers, params: { include_archived: includeArchived } },
|
||||||
|
);
|
||||||
|
return res.data.templates ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
||||||
|
headers,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
|
||||||
|
const detail = await this.getTemplate(templateId);
|
||||||
|
const created = await this.createTemplate({
|
||||||
|
title,
|
||||||
|
subject: detail.subject ?? undefined,
|
||||||
|
exam_id: detail.exam_id ?? undefined,
|
||||||
|
exam_code: detail.exam_code ?? undefined,
|
||||||
|
source_file_id: detail.source_file_id ?? undefined,
|
||||||
|
page_count: detail.page_count ?? undefined,
|
||||||
|
institute_id: detail.institute_id,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await this.archiveTemplate(created.id);
|
||||||
|
} catch (archiveError) {
|
||||||
|
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
|
||||||
|
templateId: created.id,
|
||||||
|
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
async archiveTemplate(templateId: string): Promise<void> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
|
||||||
|
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
|
||||||
|
{ headers, params: search ? { q: search } : undefined },
|
||||||
|
);
|
||||||
|
if (Array.isArray(res.data)) return res.data;
|
||||||
|
return res.data.points ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
include_archived: params.includeArchived ?? false,
|
||||||
|
template_id: params.templateId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data.batches ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchQueue(batchId: string): Promise<BatchQueueResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchCsv(batchId: string): Promise<string> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default examRepository;
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { supabase } from '../supabaseClient';
|
||||||
|
import {
|
||||||
|
Class,
|
||||||
|
ClassWithRole,
|
||||||
|
ClassWithRelations,
|
||||||
|
ClassFilters,
|
||||||
|
Timetable,
|
||||||
|
TimetableWithRelations,
|
||||||
|
TimetableLesson,
|
||||||
|
Lesson,
|
||||||
|
LessonWithRelations,
|
||||||
|
EnrollmentRequest,
|
||||||
|
EnrollmentRequestWithProfile,
|
||||||
|
} from '../types/timetable.types';
|
||||||
|
|
||||||
|
// API base URL
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper: Get auth headers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) {
|
||||||
|
throw new Error('No authentication token available');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Class Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const classService = {
|
||||||
|
async listClasses(filters?: ClassFilters): Promise<{ classes: Class[]; total: number }> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters?.subject) params.append('subject', filters.subject);
|
||||||
|
if (filters?.school_year) params.append('school_year', filters.school_year);
|
||||||
|
if (filters?.academic_term) params.append('academic_term', filters.academic_term);
|
||||||
|
if (filters?.search) params.append('search', filters.search);
|
||||||
|
if (filters?.skip !== undefined) params.append('skip', filters.skip.toString());
|
||||||
|
if (filters?.limit !== undefined) params.append('limit', filters.limit.toString());
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/classes?${params}`, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getClass(classId: string): Promise<ClassWithRelations> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/classes/${classId}`, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createClass(data: {
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
school_year: string;
|
||||||
|
academic_term: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<Class> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.post(`${API_BASE}/database/timetable/classes`, data, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateClass(classId: string, data: Partial<Class>): Promise<Class> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.patch(`${API_BASE}/database/timetable/classes/${classId}`, data, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteClass(classId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}`, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMyClasses(): Promise<ClassWithRole[]> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const [teacherRes, studentRes] = await Promise.allSettled([
|
||||||
|
axios.get(`${API_BASE}/database/timetable/classes/me/teacher`, { headers }),
|
||||||
|
axios.get(`${API_BASE}/database/timetable/classes/me/student`, { headers }),
|
||||||
|
]);
|
||||||
|
const teacherClasses: ClassWithRole[] = teacherRes.status === 'fulfilled'
|
||||||
|
? (teacherRes.value.data.classes || []).map((c: Class) => ({
|
||||||
|
...c,
|
||||||
|
class_id: c.id,
|
||||||
|
role: 'teacher' as const,
|
||||||
|
status: 'active',
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const studentClasses: ClassWithRole[] = studentRes.status === 'fulfilled'
|
||||||
|
? (studentRes.value.data.classes || []).map((c: Class) => ({
|
||||||
|
...c,
|
||||||
|
class_id: c.id,
|
||||||
|
role: 'student' as const,
|
||||||
|
status: 'active',
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const seen = new Set(teacherClasses.map(c => c.id));
|
||||||
|
return [...teacherClasses, ...studentClasses.filter(c => !seen.has(c.id))];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMyTeachingClasses(): Promise<Class[]> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/classes/me/teacher`, { headers });
|
||||||
|
return response.data.classes;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Teacher management
|
||||||
|
async addTeacher(classId: string, teacherId: string, isPrimary: boolean = false): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(
|
||||||
|
`${API_BASE}/database/timetable/classes/${classId}/teachers`,
|
||||||
|
{ teacher_id: teacherId, is_primary: isPrimary },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeTeacher(classId: string, teacherId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}/teachers/${teacherId}`, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Student management
|
||||||
|
async addStudent(classId: string, studentId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(
|
||||||
|
`${API_BASE}/database/timetable/classes/${classId}/students`,
|
||||||
|
{ student_id: studentId },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeStudent(classId: string, studentId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}/students/${studentId}`, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
async leaveClass(classId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(`${API_BASE}/database/timetable/classes/${classId}/leave`, {}, { headers });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timetable Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const timetableOnlyService = {
|
||||||
|
async listTimetables(filters?: {
|
||||||
|
class_id?: string;
|
||||||
|
type?: 'ad-hoc' | 'recurring';
|
||||||
|
active?: boolean;
|
||||||
|
}): Promise<Timetable[]> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters?.class_id) params.append('class_id', filters.class_id);
|
||||||
|
if (filters?.type) params.append('type', filters.type);
|
||||||
|
if (filters?.active !== undefined) params.append('active', filters.active.toString());
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/timetables?${params}`, { headers });
|
||||||
|
return response.data.timetables;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTimetable(timetableId: string): Promise<TimetableWithRelations> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/timetables/${timetableId}`, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTimetable(data: {
|
||||||
|
class_id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'ad-hoc' | 'recurring';
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
recurrence_pattern?: Record<string, unknown>;
|
||||||
|
recurrence_rule?: string;
|
||||||
|
whiteboard_type?: 'single' | 'multiple';
|
||||||
|
}): Promise<Timetable> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.post(`${API_BASE}/database/timetable/timetables`, data, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTimetable(timetableId: string, data: Partial<Timetable>): Promise<Timetable> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.patch(`${API_BASE}/database/timetable/timetables/${timetableId}`, data, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTimetable(timetableId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.delete(`${API_BASE}/database/timetable/timetables/${timetableId}`, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateLessons(timetableId: string, startDate: string, endDate: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(
|
||||||
|
`${API_BASE}/database/timetable/timetables/${timetableId}/generate-lessons`,
|
||||||
|
{ start_date: startDate, end_date: endDate },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timetable Teachers
|
||||||
|
async addTimetableTeacher(timetableId: string, teacherId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(
|
||||||
|
`${API_BASE}/database/timetable/timetables/${timetableId}/teachers`,
|
||||||
|
{ teacher_id: teacherId },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeTimetableTeacher(timetableId: string, teacherId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.delete(`${API_BASE}/database/timetable/timetables/${timetableId}/teachers/${teacherId}`, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lessons from timetable
|
||||||
|
async getTimetableLessons(timetableId: string): Promise<TimetableLesson[]> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/timetables/${timetableId}/lessons`, { headers });
|
||||||
|
return response.data.lessons;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lesson Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const lessonService = {
|
||||||
|
async listLessons(filters?: {
|
||||||
|
timetable_id?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
status?: 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
|
||||||
|
}): Promise<Lesson[]> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters?.timetable_id) params.append('timetable_id', filters.timetable_id);
|
||||||
|
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||||
|
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||||
|
if (filters?.status) params.append('status', filters.status);
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/lessons?${params}`, { headers });
|
||||||
|
return response.data.lessons;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLesson(lessonId: string): Promise<LessonWithRelations> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/lessons/${lessonId}`, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createLesson(data: {
|
||||||
|
timetable_id: string;
|
||||||
|
scheduled_start: string;
|
||||||
|
scheduled_end: string;
|
||||||
|
teacher_id?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<Lesson> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.post(`${API_BASE}/database/timetable/lessons`, data, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateLesson(lessonId: string, data: Partial<Lesson>): Promise<Lesson> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.patch(`${API_BASE}/database/timetable/lessons/${lessonId}`, data, { headers });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteLesson(lessonId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.delete(`${API_BASE}/database/timetable/lessons/${lessonId}`, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
async startLesson(lessonId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(`${API_BASE}/database/timetable/lessons/${lessonId}/start`, {}, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
async endLesson(lessonId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(`${API_BASE}/database/timetable/lessons/${lessonId}/end`, {}, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelLesson(lessonId: string, reason?: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(
|
||||||
|
`${API_BASE}/database/timetable/lessons/${lessonId}/cancel`,
|
||||||
|
{ reason },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enrollment Request Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const enrollmentService = {
|
||||||
|
async requestEnrollment(classId: string, message?: string): Promise<EnrollmentRequest> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_BASE}/database/timetable/classes/${classId}/enroll`,
|
||||||
|
{ message },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listEnrollmentRequests(classId: string): Promise<EnrollmentRequestWithProfile[]> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await axios.get(`${API_BASE}/database/timetable/classes/${classId}/enrollment-requests`, { headers });
|
||||||
|
return response.data.requests;
|
||||||
|
},
|
||||||
|
|
||||||
|
async respondToEnrollment(requestId: string, status: 'approved' | 'rejected'): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(
|
||||||
|
`${API_BASE}/database/timetable/enrollment-requests/${requestId}/respond`,
|
||||||
|
{ status },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelEnrollmentRequest(requestId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
await axios.post(`${API_BASE}/database/timetable/enrollment-requests/${requestId}/cancel`, {}, { headers });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Combined Export
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const timetableService = {
|
||||||
|
...classService,
|
||||||
|
...lessonService,
|
||||||
|
...enrollmentService,
|
||||||
|
|
||||||
|
// Aliases for store compatibility
|
||||||
|
getClasses: classService.listClasses,
|
||||||
|
getClass: classService.getClass,
|
||||||
|
createClass: classService.createClass,
|
||||||
|
updateClass: classService.updateClass,
|
||||||
|
deleteClass: classService.deleteClass,
|
||||||
|
getMyClasses: classService.getMyClasses,
|
||||||
|
getMyTeachingClasses: classService.getMyTeachingClasses,
|
||||||
|
addTeacherToClass: classService.addTeacher,
|
||||||
|
removeTeacherFromClass: classService.removeTeacher,
|
||||||
|
addStudentToClass: classService.addStudent,
|
||||||
|
removeStudentFromClass: classService.removeStudent,
|
||||||
|
|
||||||
|
// Timetable aliases
|
||||||
|
getTimetables: timetableOnlyService.listTimetables,
|
||||||
|
getTimetable: timetableOnlyService.getTimetable,
|
||||||
|
createTimetable: timetableOnlyService.createTimetable,
|
||||||
|
updateTimetable: timetableOnlyService.updateTimetable,
|
||||||
|
deleteTimetable: timetableOnlyService.deleteTimetable,
|
||||||
|
getMyTimetables: timetableOnlyService.getMyTimetables,
|
||||||
|
getMyTeachingTimetables: timetableOnlyService.getMyTeachingTimetables,
|
||||||
|
addTeacherToTimetable: timetableOnlyService.addTeacher,
|
||||||
|
removeTeacherFromTimetable: timetableOnlyService.removeTeacher,
|
||||||
|
addTimetableLesson: timetableOnlyService.addLesson,
|
||||||
|
updateTimetableLesson: timetableOnlyService.updateLesson,
|
||||||
|
removeTimetableLesson: timetableOnlyService.removeLesson,
|
||||||
|
|
||||||
|
// Lesson aliases
|
||||||
|
getLessons: lessonService.listLessons,
|
||||||
|
getLesson: lessonService.getLesson,
|
||||||
|
generateLessons: lessonService.generateLessons,
|
||||||
|
cancelLesson: lessonService.cancelLesson,
|
||||||
|
getMyLessons: lessonService.getMyLessons,
|
||||||
|
getMyTeachingLessons: lessonService.getMyTeachingLessons,
|
||||||
|
saveWhiteboard: lessonService.saveWhiteboard,
|
||||||
|
getWhiteboard: lessonService.getWhiteboard,
|
||||||
|
exportWhiteboard: lessonService.exportWhiteboard,
|
||||||
|
|
||||||
|
// Enrollment aliases
|
||||||
|
getEnrollmentRequests: enrollmentService.listEnrollmentRequests,
|
||||||
|
requestEnrollment: enrollmentService.requestEnrollment,
|
||||||
|
respondToEnrollmentRequest: enrollmentService.respondToEnrollment,
|
||||||
|
cancelEnrollmentRequest: enrollmentService.cancelEnrollmentRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default timetableOnlyService;
|
||||||
@@ -19,12 +19,17 @@ export class SharedStoreService {
|
|||||||
private lastSaveTime: number = Date.now();
|
private lastSaveTime: number = Date.now();
|
||||||
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
|
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
|
||||||
private config: AutoSaveConfig;
|
private config: AutoSaveConfig;
|
||||||
|
private isDirty = false;
|
||||||
|
private dirtyListener: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
|
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
|
||||||
this.config = {
|
this.config = {
|
||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
...config
|
...config
|
||||||
};
|
};
|
||||||
|
this.dirtyListener = store.listen(() => {
|
||||||
|
this.isDirty = true;
|
||||||
|
});
|
||||||
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
|
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,18 +57,16 @@ export class SharedStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
|
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
|
||||||
const now = Date.now();
|
if (!this.isDirty) {
|
||||||
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
return;
|
||||||
const currentSnapshot = getSnapshot(this.store);
|
|
||||||
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
|
|
||||||
|
|
||||||
if (!savedSnapshot || JSON.stringify(currentSnapshot) !== JSON.stringify(savedSnapshot)) {
|
|
||||||
logger.debug('shared-store-service', '💾 Auto-saving snapshot - changes detected');
|
|
||||||
await this.saveSnapshot(currentSnapshot, setLoadingState);
|
|
||||||
this.lastSaveTime = now;
|
|
||||||
} else {
|
|
||||||
logger.trace('shared-store-service', '📝 No changes detected, skipping auto-save');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isDirty = false;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
||||||
|
await this.saveSnapshot(getSnapshot(this.store), setLoadingState);
|
||||||
|
this.lastSaveTime = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +112,10 @@ export class SharedStoreService {
|
|||||||
|
|
||||||
public clear(): void {
|
public clear(): void {
|
||||||
this.stopAutoSave();
|
this.stopAutoSave();
|
||||||
|
this.dirtyListener?.();
|
||||||
|
this.dirtyListener = null;
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
|
this.isDirty = false;
|
||||||
logger.debug('shared-store-service', '🧹 Store cleared');
|
logger.debug('shared-store-service', '🧹 Store cleared');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,52 @@
|
|||||||
// External imports
|
// External imports
|
||||||
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
|
||||||
import axios from '../../axiosConfig';
|
|
||||||
import logger from '../../debugConfig';
|
import logger from '../../debugConfig';
|
||||||
import { SharedStoreService } from './sharedStoreService';
|
import { SharedStoreService } from './sharedStoreService';
|
||||||
import { StorageKeys, storageService } from '../auth/localStorageService';
|
|
||||||
import { NavigationNode } from '../../types/navigation';
|
|
||||||
|
|
||||||
export interface LoadingState {
|
export interface LoadingState {
|
||||||
status: 'loading' | 'ready' | 'error';
|
status: 'loading' | 'ready' | 'error';
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_NODE: NavigationNode = {
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
|
||||||
id: '',
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
|
||||||
node_storage_path: '',
|
const BUCKET = 'cc.users';
|
||||||
type: '',
|
|
||||||
label: ''
|
async function storageGet(path: string, accessToken: string): Promise<unknown | null> {
|
||||||
|
const url = `${SUPABASE_URL}/storage/v1/object/authenticated/${BUCKET}/${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
apikey: SUPABASE_ANON_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 404 || res.status === 400) return null;
|
||||||
|
if (!res.ok) throw new Error(`Storage GET ${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storagePut(path: string, accessToken: string, data: unknown): Promise<void> {
|
||||||
|
const url = `${SUPABASE_URL}/storage/v1/object/${BUCKET}/${path}`;
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
apikey: SUPABASE_ANON_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
const body = JSON.stringify(data);
|
||||||
|
// PUT replaces an existing object; POST creates a new one.
|
||||||
|
// Avoids x-upsert custom header which self-hosted Supabase CORS may block.
|
||||||
|
let res = await fetch(url, { method: 'PUT', headers, body });
|
||||||
|
if (!res.ok && (res.status === 404 || res.status === 400)) {
|
||||||
|
res = await fetch(url, { method: 'POST', headers, body });
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`Storage ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export class NavigationSnapshotService {
|
export class NavigationSnapshotService {
|
||||||
private store: TLStore;
|
private store: TLStore;
|
||||||
private editor: Editor | null = null;
|
private editor: Editor | null = null;
|
||||||
private currentNodePath: string | null = null;
|
private currentNodePath: string | null = null;
|
||||||
|
private _accessToken: string | null = null;
|
||||||
private isAutoSaveEnabled = true;
|
private isAutoSaveEnabled = true;
|
||||||
private isSaving = false;
|
private isSaving = false;
|
||||||
private isLoading = false;
|
private isLoading = false;
|
||||||
@@ -33,24 +58,21 @@ export class NavigationSnapshotService {
|
|||||||
this.editor = editor || null;
|
this.editor = editor || null;
|
||||||
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
|
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
hasEditor: !!editor
|
hasEditor: !!editor,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditor(editor: Editor): void {
|
setEditor(editor: Editor): void {
|
||||||
this.editor = editor;
|
this.editor = editor;
|
||||||
logger.debug('snapshot-service', '🔄 Editor reference updated', {
|
|
||||||
editorId: editor.store.id
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static replaceBackslashes(input: string | undefined): string {
|
setAccessToken(token: string): void {
|
||||||
return input ? input.replace(/\\/g, '/') : '';
|
this._accessToken = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async loadNodeSnapshotFromDatabase(
|
static async loadNodeSnapshotFromDatabase(
|
||||||
nodePath: string,
|
nodePath: string,
|
||||||
dbName: string,
|
accessToken: string,
|
||||||
store: TLStore,
|
store: TLStore,
|
||||||
setLoadingState: (state: LoadingState) => void,
|
setLoadingState: (state: LoadingState) => void,
|
||||||
sharedStore?: SharedStoreService,
|
sharedStore?: SharedStoreService,
|
||||||
@@ -58,248 +80,121 @@ export class NavigationSnapshotService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
setLoadingState({ status: 'loading', error: '' });
|
||||||
|
logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath });
|
||||||
|
|
||||||
logger.info('snapshot-service', '📂 Loading file from path', {
|
const snapshot = await storageGet(nodePath, accessToken);
|
||||||
path: nodePath,
|
|
||||||
db_name: dbName
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await axios.get(
|
if (!snapshot) {
|
||||||
'/database/tldraw_supabase/get_tldraw_node_file', {
|
logger.debug('snapshot-service', 'ℹ️ No snapshot found at path — clearing canvas', { nodePath });
|
||||||
params: {
|
// Clear all shapes so the canvas is blank for this new node
|
||||||
path: this.replaceBackslashes(nodePath),
|
if (editor) {
|
||||||
db_name: dbName
|
const shapeIds = [...editor.getCurrentPageShapeIds()];
|
||||||
|
if (shapeIds.length > 0) {
|
||||||
|
editor.deleteShapes(shapeIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = response.data;
|
const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown };
|
||||||
logger.debug('snapshot-service', '🔍 Snapshot data received', {
|
if (!snap.document || !snap.session) {
|
||||||
hasSnapshot: !!snapshot,
|
logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath });
|
||||||
hasDocument: !!snapshot?.document,
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
hasSession: !!snapshot?.session,
|
return;
|
||||||
hasSchemaVersion: !!snapshot?.schemaVersion,
|
}
|
||||||
schemaVersion: snapshot?.schemaVersion,
|
|
||||||
snapshotKeys: snapshot ? Object.keys(snapshot) : []
|
|
||||||
});
|
|
||||||
|
|
||||||
if (snapshot && snapshot.document && snapshot.session) {
|
|
||||||
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
|
|
||||||
|
|
||||||
if (sharedStore) {
|
if (sharedStore) {
|
||||||
await sharedStore.loadSnapshot(snapshot, setLoadingState);
|
await sharedStore.loadSnapshot(snapshot, setLoadingState);
|
||||||
} else {
|
return;
|
||||||
logger.debug('snapshot-service', '🔄 Calling TLDraw loadSnapshot', {
|
}
|
||||||
hasStore: !!store,
|
|
||||||
snapshotType: typeof snapshot,
|
// Sanitize session.currentPageId against pages actually in the document
|
||||||
snapshotKeys: Object.keys(snapshot),
|
const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
|
||||||
snapshotSchemaVersion: snapshot?.schemaVersion,
|
const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
|
||||||
snapshotDocument: !!snapshot?.document,
|
const session = snap.session as { currentPageId?: string } | undefined;
|
||||||
snapshotSession: !!snapshot?.session
|
if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) {
|
||||||
|
logger.warn('snapshot-service', '⚠️ session.currentPageId not in snapshot pages — resetting to first page', {
|
||||||
|
currentPageId: session.currentPageId, availablePages: pageIds
|
||||||
});
|
});
|
||||||
|
session.currentPageId = pageIds[0];
|
||||||
|
}
|
||||||
|
|
||||||
// Create a defensive copy to ensure the snapshot doesn't get modified
|
|
||||||
const snapshotCopy = {
|
const snapshotCopy = {
|
||||||
schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion,
|
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
||||||
document: snapshot.document,
|
document: snap.document,
|
||||||
session: snapshot.session
|
session: snap.session,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.debug('snapshot-service', '🔄 Calling loadSnapshot with defensive copy', {
|
const targetStore = editor ? editor.store : store;
|
||||||
copySchemaVersion: snapshotCopy.schemaVersion,
|
|
||||||
copyDocument: !!snapshotCopy.document,
|
|
||||||
copySession: !!snapshotCopy.session,
|
|
||||||
storeType: typeof store,
|
|
||||||
storeIsNull: store === null,
|
|
||||||
storeIsUndefined: store === undefined,
|
|
||||||
storeKeys: store ? Object.keys(store) : 'N/A'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug: Log the snapshot schema sequences
|
|
||||||
if (snapshotCopy.document?.schema?.sequences) {
|
|
||||||
logger.debug('snapshot-service', '🔍 Snapshot schema sequences:', snapshotCopy.document.schema.sequences);
|
|
||||||
const customSequences = Object.keys(snapshotCopy.document.schema.sequences).filter(key => key.includes('cc-'));
|
|
||||||
logger.debug('snapshot-service', '🔍 Custom shape sequences in snapshot:', customSequences);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: Log the store schema sequences
|
|
||||||
if (store?.schema) {
|
|
||||||
const storeSequences = store.schema.serialize().sequences;
|
|
||||||
logger.debug('snapshot-service', '🔍 Store schema sequences:', storeSequences);
|
|
||||||
const storeCustomSequences = Object.keys(storeSequences).filter(key => key.includes('cc-'));
|
|
||||||
logger.debug('snapshot-service', '🔍 Custom shape sequences in store:', storeCustomSequences);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add try-catch around the loadSnapshot call to get more specific error info
|
|
||||||
try {
|
try {
|
||||||
// Ensure store is properly initialized before loading snapshot
|
loadSnapshot(targetStore, snapshotCopy as any);
|
||||||
if (!store) {
|
// Post-load validation: ensure instance.currentPageId is still valid
|
||||||
throw new Error('Store is null or undefined');
|
const instance = targetStore.get(TLINSTANCE_ID);
|
||||||
|
if (instance) {
|
||||||
|
const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page');
|
||||||
|
const pageValid = pages.some((p) => p.id === instance.currentPageId);
|
||||||
|
if (!pageValid) {
|
||||||
|
logger.warn('snapshot-service', '⚠️ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId });
|
||||||
|
targetStore.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate snapshot structure before loading
|
|
||||||
if (!snapshotCopy || !snapshotCopy.document || !snapshotCopy.session) {
|
|
||||||
throw new Error('Invalid snapshot structure');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for schema migrations and handle them properly
|
|
||||||
logger.debug('snapshot-service', '🔄 Checking for schema migrations', {
|
|
||||||
storeId: store.id,
|
|
||||||
storeType: typeof store,
|
|
||||||
storeConstructor: store.constructor.name,
|
|
||||||
snapshotSchemaVersion: snapshotCopy.schemaVersion,
|
|
||||||
snapshotDocumentKeys: Object.keys(snapshotCopy.document || {}),
|
|
||||||
snapshotSessionKeys: Object.keys(snapshotCopy.session || {})
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to load the snapshot directly first
|
|
||||||
logger.debug('snapshot-service', '🔄 Attempting to load snapshot directly');
|
|
||||||
if (editor) {
|
|
||||||
loadSnapshot(editor.store, snapshotCopy);
|
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const isSchemaMigration = /migration|schema|Incompatible/i.test(msg);
|
||||||
|
if (isSchemaMigration) {
|
||||||
|
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
||||||
} else {
|
} else {
|
||||||
// Fallback: use global loadSnapshot if no editor available
|
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
|
||||||
logger.debug('snapshot-service', '🔄 No editor available, using global loadSnapshot');
|
targetStore.clear();
|
||||||
loadSnapshot(store, snapshotCopy);
|
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully via global loadSnapshot');
|
|
||||||
}
|
|
||||||
} catch (migrationError) {
|
|
||||||
// Check if this is a schema migration error that we can safely ignore
|
|
||||||
const errorMessage = migrationError instanceof Error ? migrationError.message : String(migrationError);
|
|
||||||
const isSchemaMigrationError = errorMessage.includes('migration') ||
|
|
||||||
errorMessage.includes('schema') ||
|
|
||||||
errorMessage.includes('Incompatible');
|
|
||||||
|
|
||||||
if (isSchemaMigrationError) {
|
|
||||||
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', {
|
|
||||||
error: errorMessage
|
|
||||||
});
|
|
||||||
// Continue with empty store - this is expected for some snapshots
|
|
||||||
} else {
|
|
||||||
logger.warn('snapshot-service', '⚠️ Unexpected load error', {
|
|
||||||
error: errorMessage
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('snapshot-service', '✅ loadSnapshot call succeeded');
|
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
} catch (loadError) {
|
|
||||||
logger.error('snapshot-service', '❌ loadSnapshot call failed', {
|
|
||||||
error: loadError instanceof Error ? loadError.message : String(loadError),
|
|
||||||
storeType: typeof store,
|
|
||||||
storeHasLoadSnapshot: store && typeof store.loadSnapshot === 'function',
|
|
||||||
snapshotType: typeof snapshotCopy,
|
|
||||||
snapshotKeys: Object.keys(snapshotCopy)
|
|
||||||
});
|
|
||||||
throw loadError;
|
|
||||||
}
|
|
||||||
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error('snapshot-service', '❌ Invalid snapshot format');
|
|
||||||
setLoadingState({ status: 'error', error: 'Invalid snapshot format' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Failed to fetch snapshot', {
|
logger.error('snapshot-service', '❌ Failed to load snapshot', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
setLoadingState({
|
setLoadingState({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: error instanceof Error ? error.message : 'Failed to load file'
|
error: error instanceof Error ? error.message : 'Failed to load snapshot',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async saveNodeSnapshotToDatabase(
|
static async saveNodeSnapshotToDatabase(
|
||||||
nodePath: string,
|
nodePath: string,
|
||||||
dbName: string,
|
accessToken: string,
|
||||||
store: TLStore
|
store: TLStore
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info('snapshot-service', '💾 Saving snapshot to database', {
|
logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath });
|
||||||
path: nodePath,
|
|
||||||
db_name: dbName
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = getSnapshot(store);
|
const snapshot = getSnapshot(store);
|
||||||
|
await storagePut(nodePath, accessToken, snapshot);
|
||||||
// Debug: Log what we're saving
|
|
||||||
logger.debug('snapshot-service', '🔍 Snapshot being saved:', {
|
|
||||||
hasSnapshot: !!snapshot,
|
|
||||||
snapshotKeys: Object.keys(snapshot || {}),
|
|
||||||
schemaVersion: snapshot?.schemaVersion,
|
|
||||||
hasDocument: !!snapshot?.document,
|
|
||||||
hasSession: !!snapshot?.session
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug: Log the schema sequences in the snapshot being saved
|
|
||||||
if (snapshot?.document?.schema?.sequences) {
|
|
||||||
logger.debug('snapshot-service', '🔍 Schema sequences being saved:', snapshot.document.schema.sequences);
|
|
||||||
const customSequences = Object.keys(snapshot.document.schema.sequences).filter(key => key.includes('cc-'));
|
|
||||||
logger.debug('snapshot-service', '🔍 Custom shape sequences being saved:', customSequences);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.post(
|
|
||||||
'/database/tldraw_supabase/set_tldraw_node_file',
|
|
||||||
snapshot,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
path: this.replaceBackslashes(nodePath),
|
|
||||||
db_name: dbName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data.status === 'success') {
|
|
||||||
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
|
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
|
||||||
} else {
|
|
||||||
throw new Error('Failed to save snapshot');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Failed to save snapshot', {
|
logger.error('snapshot-service', '❌ Failed to save snapshot', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveCurrentSnapshot(nodePath: string): Promise<void> {
|
private async saveCurrentSnapshot(nodePath: string): Promise<void> {
|
||||||
if (!this.currentNodePath || this.currentNodePath !== nodePath) {
|
if (!this.currentNodePath || this.currentNodePath !== nodePath) return;
|
||||||
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', {
|
if (!this._accessToken) {
|
||||||
currentPath: this.currentNodePath,
|
logger.debug('snapshot-service', '⚠️ No access token — snapshot save skipped');
|
||||||
savePath: nodePath
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
const user = storageService.get(StorageKeys.USER);
|
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store);
|
||||||
if (!user) {
|
logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath });
|
||||||
throw new Error('No user found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbName = user.user_db_name;
|
|
||||||
|
|
||||||
logger.debug('snapshot-service', '💾 Saving snapshot', {
|
|
||||||
nodePath,
|
|
||||||
dbName,
|
|
||||||
userType: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, dbName, this.store);
|
|
||||||
|
|
||||||
logger.debug('snapshot-service', '✅ Saved navigation snapshot', {
|
|
||||||
nodePath,
|
|
||||||
storeId: this.store.id
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
|
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
nodePath
|
nodePath,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -307,137 +202,77 @@ export class NavigationSnapshotService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadSnapshotForNode(node: NavigationNode): Promise<void> {
|
private async loadSnapshotForNode(node: { node_storage_path: string }): Promise<void> {
|
||||||
|
if (!this._accessToken) {
|
||||||
|
logger.debug('snapshot-service', '⚠️ No access token — snapshot load skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const user = storageService.get(StorageKeys.USER);
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('No user found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbName = user.user_db_name;
|
|
||||||
|
|
||||||
logger.debug('snapshot-service', '📥 Loading snapshot', {
|
|
||||||
nodePath: node.node_storage_path,
|
|
||||||
dbName,
|
|
||||||
userType: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
node.node_storage_path,
|
node.node_storage_path,
|
||||||
dbName,
|
this._accessToken,
|
||||||
this.store,
|
this.store,
|
||||||
(state: LoadingState) => {
|
(state: LoadingState) => {
|
||||||
if (state.status === 'ready') {
|
if (state.status === 'ready') {
|
||||||
this.currentNodePath = node.node_storage_path;
|
this.currentNodePath = node.node_storage_path;
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
|
|
||||||
nodePath: node.node_storage_path,
|
|
||||||
currentNodePath: this.currentNodePath
|
|
||||||
});
|
|
||||||
} else if (state.status === 'error') {
|
|
||||||
logger.error('snapshot-service', '❌ Error in load callback', {
|
|
||||||
error: state.error,
|
|
||||||
nodePath: node.node_storage_path
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined, // sharedStore
|
undefined,
|
||||||
this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot
|
this.editor || undefined
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
nodePath: node.node_storage_path
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> {
|
async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise<void> {
|
||||||
if (!toNode) {
|
if (!toNode) return;
|
||||||
logger.warn('snapshot-service', '⚠️ Cannot navigate to null node');
|
if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any pending debounce
|
|
||||||
if (this.debounceTimeout) {
|
|
||||||
clearTimeout(this.debounceTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce the navigation operation
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.debounceTimeout = setTimeout(async () => {
|
this.debounceTimeout = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await this.executeNavigation(fromNode || EMPTY_NODE, toNode);
|
await this.executeNavigation(fromNode, toNode);
|
||||||
resolve();
|
resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('snapshot-service', '❌ Navigation failed', error);
|
logger.error('snapshot-service', '❌ Navigation failed', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, 100); // 100ms debounce
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
|
private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise<void> {
|
||||||
try {
|
|
||||||
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
|
|
||||||
from: fromNode.node_storage_path,
|
|
||||||
to: toNode.node_storage_path,
|
|
||||||
currentPath: this.currentNodePath
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we're already in a navigation operation, queue this one
|
|
||||||
if (this.isSaving || this.isLoading) {
|
if (this.isSaving || this.isLoading) {
|
||||||
this.pendingOperation = {
|
this.pendingOperation = {
|
||||||
save: fromNode.node_storage_path || undefined,
|
save: fromNode?.node_storage_path,
|
||||||
load: toNode.node_storage_path
|
load: toNode.node_storage_path,
|
||||||
};
|
};
|
||||||
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the store before loading new snapshot
|
|
||||||
logger.debug('snapshot-service', '🔄 Clearing store');
|
|
||||||
this.currentNodePath = null;
|
this.currentNodePath = null;
|
||||||
logger.debug('snapshot-service', '🧹 Cleared current node path');
|
|
||||||
|
|
||||||
// Load the new node's snapshot
|
|
||||||
if (toNode.node_storage_path) {
|
if (toNode.node_storage_path) {
|
||||||
await this.loadSnapshotForNode(toNode);
|
await this.loadSnapshotForNode(toNode);
|
||||||
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
|
|
||||||
nodePath: toNode.node_storage_path
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process any pending operations
|
|
||||||
if (this.pendingOperation) {
|
if (this.pendingOperation) {
|
||||||
logger.debug('snapshot-service', '🔄 Processing pending operation', this.pendingOperation);
|
const op = this.pendingOperation;
|
||||||
const operation = this.pendingOperation;
|
|
||||||
this.pendingOperation = null;
|
this.pendingOperation = null;
|
||||||
await this.handleNavigationStart(
|
await this.handleNavigationStart(
|
||||||
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null,
|
op.save ? { node_storage_path: op.save } : null,
|
||||||
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null
|
op.load ? { node_storage_path: op.load } : null
|
||||||
);
|
);
|
||||||
logger.debug('snapshot-service', '✅ Completed pending operation');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('snapshot-service', '❌ Error during navigation snapshot handling', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
fromPath: fromNode.node_storage_path,
|
|
||||||
toPath: toNode.node_storage_path
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutoSave(enabled: boolean): void {
|
setAutoSave(enabled: boolean): void {
|
||||||
this.isAutoSaveEnabled = enabled;
|
this.isAutoSaveEnabled = enabled;
|
||||||
logger.debug('snapshot-service', '🔄 Auto-save setting changed', {
|
}
|
||||||
enabled
|
|
||||||
});
|
setCurrentNodePath(path: string): void {
|
||||||
|
this.currentNodePath = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentNodePath(): string | null {
|
getCurrentNodePath(): string | null {
|
||||||
@@ -447,14 +282,11 @@ export class NavigationSnapshotService {
|
|||||||
async forceSaveCurrentNode(): Promise<void> {
|
async forceSaveCurrentNode(): Promise<void> {
|
||||||
if (this.currentNodePath) {
|
if (this.currentNodePath) {
|
||||||
await this.saveCurrentSnapshot(this.currentNodePath);
|
await this.saveCurrentSnapshot(this.currentNodePath);
|
||||||
} else {
|
|
||||||
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCurrentNode(): void {
|
clearCurrentNode(): void {
|
||||||
this.currentNodePath = null;
|
this.currentNodePath = null;
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
logger.debug('snapshot-service', '🧹 Cleared current node and store');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ export interface SyncConnectionOptions {
|
|||||||
color: string;
|
color: string;
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSyncConnectionOptions(options: SyncConnectionOptions) {
|
export function createSyncConnectionOptions(options: SyncConnectionOptions) {
|
||||||
@@ -22,8 +23,8 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
|
|||||||
userId,
|
userId,
|
||||||
displayName,
|
displayName,
|
||||||
roomId = 'multiplayer',
|
roomId = 'multiplayer',
|
||||||
baseUrl
|
baseUrl,
|
||||||
|
token
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Ensure we have valid user info
|
// Ensure we have valid user info
|
||||||
@@ -72,8 +73,10 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
|
|||||||
roomId: effectiveRoomId
|
roomId: effectiveRoomId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uri: `${baseUrl}/connect/${effectiveRoomId}`,
|
uri: `${baseUrl}/connect/${effectiveRoomId}${tokenParam}`,
|
||||||
assets: multiplayerAssets,
|
assets: multiplayerAssets,
|
||||||
roomId: effectiveRoomId
|
roomId: effectiveRoomId
|
||||||
};
|
};
|
||||||
@@ -114,4 +117,3 @@ export function generateSharedRoomId(path: string): string {
|
|||||||
const sanitizedPath = path.replace(/[^a-zA-Z0-9]/g, '-');
|
const sanitizedPath = path.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
return `shared-${sanitizedPath}`;
|
return `shared-${sanitizedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { expect } from 'vitest';
|
import { expect } from 'vitest';
|
||||||
|
|
||||||
|
if (typeof globalThis.matchMedia !== 'function') {
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: false,
|
||||||
|
media: '',
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
} as unknown as MediaQueryList))
|
||||||
|
}
|
||||||
|
|||||||
+198
-299
@@ -1,32 +1,45 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { UserNeoDBService } from '../services/graph/userNeoDBService';
|
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
|
||||||
import {
|
import {
|
||||||
NavigationStore,
|
NavigationStore,
|
||||||
NavigationNode,
|
NavigationNode,
|
||||||
|
NeoGraphNode,
|
||||||
MainContext,
|
MainContext,
|
||||||
BaseContext,
|
BaseContext,
|
||||||
NavigationContextState,
|
NavigationContextState,
|
||||||
isProfileContext,
|
isProfileContext,
|
||||||
isInstituteContext,
|
isInstituteContext,
|
||||||
getContextDatabase,
|
|
||||||
addToHistory,
|
addToHistory,
|
||||||
navigateHistory,
|
navigateHistory,
|
||||||
getCurrentHistoryNode,
|
getCurrentHistoryNode,
|
||||||
ExtendedContext,
|
ExtendedContext,
|
||||||
UnifiedContextSwitch,
|
UnifiedContextSwitch,
|
||||||
NodeContext
|
|
||||||
} from '../types/navigation';
|
} from '../types/navigation';
|
||||||
|
|
||||||
|
interface WhiteboardRoom {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
context_type: string;
|
||||||
|
is_default: boolean;
|
||||||
|
storage_path: string | null;
|
||||||
|
neo4j_node_id: string | null;
|
||||||
|
neo4j_db_name: string | null;
|
||||||
|
node_type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationStoreWithAuth extends NavigationStore {
|
||||||
|
_accessToken: string | null;
|
||||||
|
_userId: string | null;
|
||||||
|
setAuthInfo: (token: string | null, userId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: NavigationContextState = {
|
const initialState: NavigationContextState = {
|
||||||
main: 'profile',
|
main: 'profile',
|
||||||
base: 'profile',
|
base: 'profile',
|
||||||
node: null,
|
node: null,
|
||||||
history: {
|
history: { nodes: [], currentIndex: -1 }
|
||||||
nodes: [],
|
|
||||||
currentIndex: -1
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDefaultBaseForMain(main: MainContext): BaseContext {
|
function getDefaultBaseForMain(main: MainContext): BaseContext {
|
||||||
@@ -38,210 +51,140 @@ function validateContextTransition(
|
|||||||
updates: Partial<NavigationContextState>
|
updates: Partial<NavigationContextState>
|
||||||
): NavigationContextState {
|
): NavigationContextState {
|
||||||
const newState = { ...current, ...updates };
|
const newState = { ...current, ...updates };
|
||||||
|
|
||||||
// Validate main context
|
|
||||||
if (updates.main) {
|
if (updates.main) {
|
||||||
newState.base = getDefaultBaseForMain(updates.main);
|
newState.base = getDefaultBaseForMain(updates.main);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate base context
|
|
||||||
if (updates.base) {
|
if (updates.base) {
|
||||||
// Ensure base context matches main context
|
|
||||||
const isValid = newState.main === 'profile'
|
const isValid = newState.main === 'profile'
|
||||||
? isProfileContext(updates.base)
|
? isProfileContext(updates.base)
|
||||||
: isInstituteContext(updates.base);
|
: isInstituteContext(updates.base);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
newState.base = getDefaultBaseForMain(newState.main);
|
newState.base = getDefaultBaseForMain(newState.main);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationActions {
|
export const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => {
|
||||||
// Context Navigation
|
const pgFetch = async <T = unknown>(
|
||||||
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||||
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
table: string,
|
||||||
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
|
||||||
|
): Promise<T | null> => {
|
||||||
|
const token = get()._accessToken;
|
||||||
|
if (!token) throw new Error('pgFetch: no access token');
|
||||||
|
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (options.prefer) headers['Prefer'] = options.prefer;
|
||||||
|
if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`PostgREST ${res.status}: ${err}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
// Node Navigation
|
const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => {
|
||||||
navigate: (nodeId: string, dbName: string) => Promise<void>;
|
const userId = get()._userId;
|
||||||
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID');
|
||||||
|
|
||||||
// History Navigation
|
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||||
goBack: () => void;
|
query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`,
|
||||||
goForward: () => void;
|
});
|
||||||
|
|
||||||
// Utility Methods
|
if (rooms && rooms.length > 0) {
|
||||||
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
const room = rooms[0];
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`,
|
||||||
|
label: room.name,
|
||||||
|
type: 'workspace',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationState {
|
const storagePath = `${userId}/workspaces/${contextType}_default.json`;
|
||||||
context: {
|
const room = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
|
||||||
main: NodeContext;
|
body: {
|
||||||
base: NodeContext;
|
user_id: userId,
|
||||||
extended?: string;
|
name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`,
|
||||||
node: NavigationNode;
|
context_type: contextType,
|
||||||
history: {
|
is_default: true,
|
||||||
nodes: NavigationNode[];
|
storage_path: storagePath,
|
||||||
currentIndex: number;
|
},
|
||||||
|
prefer: 'return=representation',
|
||||||
|
single: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!room) throw new Error('Failed to create default whiteboard room');
|
||||||
|
logger.debug('navigation-context', '✅ Created default whiteboard room', { contextType, roomId: room.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
node_storage_path: room.storage_path || storagePath,
|
||||||
|
label: room.name,
|
||||||
|
type: 'workspace',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
// ... rest of the state interface ...
|
|
||||||
}
|
return {
|
||||||
|
_accessToken: null,
|
||||||
|
_userId: null,
|
||||||
|
setAuthInfo: (token: string | null, userId: string | null) => {
|
||||||
|
set({ _accessToken: token, _userId: userId });
|
||||||
|
},
|
||||||
|
|
||||||
export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
|
||||||
context: initialState,
|
context: initialState,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => {
|
switchContext: async (contextSwitch: UnifiedContextSwitch, _userDbName: string | null = null, _workerDbName: string | null = null) => {
|
||||||
|
if (!get()._accessToken || !get()._userId) {
|
||||||
|
logger.warn('navigation-context', '⚠️ switchContext called without auth — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Check if we have the necessary database connections
|
|
||||||
if (contextSwitch.main === 'profile' && !userDbName) {
|
|
||||||
logger.error('navigation-context', '❌ User database connection not initialized');
|
|
||||||
set({
|
|
||||||
error: 'User database connection not initialized',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (contextSwitch.main === 'institute' && !workerDbName) {
|
|
||||||
logger.error('navigation-context', '❌ Worker database connection not initialized');
|
|
||||||
set({
|
|
||||||
error: 'Worker database connection not initialized',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('navigation-context', '🔄 Starting context switch', {
|
|
||||||
from: {
|
|
||||||
main: get().context.main,
|
|
||||||
base: get().context.base,
|
|
||||||
extended: contextSwitch.extended,
|
|
||||||
nodeId: get().context.node?.id
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
main: contextSwitch.main,
|
|
||||||
base: contextSwitch.base,
|
|
||||||
extended: contextSwitch.extended
|
|
||||||
},
|
|
||||||
skipBaseContextLoad: contextSwitch.skipBaseContextLoad
|
|
||||||
});
|
|
||||||
|
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
const currentState = get().context;
|
const currentState = get().context;
|
||||||
|
let newState: NavigationContextState = { ...currentState, node: null };
|
||||||
|
|
||||||
// Clear node state immediately
|
|
||||||
const clearedState: NavigationContextState = {
|
|
||||||
...currentState,
|
|
||||||
node: null
|
|
||||||
};
|
|
||||||
set({
|
|
||||||
context: clearedState,
|
|
||||||
isLoading: true
|
|
||||||
});
|
|
||||||
|
|
||||||
let newState: NavigationContextState = {
|
|
||||||
...currentState,
|
|
||||||
node: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update main context if provided
|
|
||||||
if (contextSwitch.main) {
|
if (contextSwitch.main) {
|
||||||
newState = validateContextTransition(newState, { main: contextSwitch.main });
|
newState = validateContextTransition(newState, { main: contextSwitch.main });
|
||||||
if (!contextSwitch.skipBaseContextLoad) {
|
if (!contextSwitch.skipBaseContextLoad) {
|
||||||
newState.base = getDefaultBaseForMain(contextSwitch.main);
|
newState.base = getDefaultBaseForMain(contextSwitch.main);
|
||||||
}
|
}
|
||||||
logger.debug('navigation-state', '✅ Main context updated', {
|
|
||||||
previous: currentState.main,
|
|
||||||
new: newState.main,
|
|
||||||
defaultBase: newState.base
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update base context if provided
|
|
||||||
if (contextSwitch.base) {
|
if (contextSwitch.base) {
|
||||||
newState = validateContextTransition(newState, { base: contextSwitch.base });
|
newState = validateContextTransition(newState, { base: contextSwitch.base });
|
||||||
logger.debug('navigation-state', '✅ Base context updated', {
|
|
||||||
previous: currentState.base,
|
|
||||||
new: newState.base
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('navigation-state', '✅ Context validation complete', {
|
|
||||||
validatedState: newState,
|
|
||||||
originalState: currentState
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine which context to use for the node
|
|
||||||
const targetContext = contextSwitch.base ||
|
const targetContext = contextSwitch.base ||
|
||||||
contextSwitch.extended ||
|
contextSwitch.extended ||
|
||||||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) :
|
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base);
|
||||||
newState.base);
|
|
||||||
|
|
||||||
// Get database name
|
const defaultNode = await getOrCreateDefaultRoom(targetContext);
|
||||||
const dbName = getContextDatabase(newState, userDbName, workerDbName);
|
|
||||||
|
|
||||||
logger.debug('context-switch', '🔍 Fetching default node for context', {
|
|
||||||
targetContext,
|
|
||||||
dbName,
|
|
||||||
currentState: newState
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get default node for the final context
|
|
||||||
const defaultNode = await UserNeoDBService.getDefaultNode(targetContext, dbName);
|
|
||||||
|
|
||||||
if (!defaultNode) {
|
|
||||||
const errorMsg = `No default node found for context: ${targetContext}`;
|
|
||||||
logger.error('context-switch', '❌ Default node fetch failed', { targetContext });
|
|
||||||
set({
|
|
||||||
error: errorMsg,
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('context-switch', '✨ Default node fetched', {
|
|
||||||
nodeId: defaultNode.id,
|
|
||||||
node_storage_path: defaultNode.node_storage_path,
|
|
||||||
type: defaultNode.type
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update history and state
|
|
||||||
const newHistory = addToHistory(currentState.history, defaultNode);
|
const newHistory = addToHistory(currentState.history, defaultNode);
|
||||||
logger.debug('history-management', '📚 History updated', {
|
|
||||||
previousState: currentState.history,
|
|
||||||
newState: newHistory,
|
|
||||||
addedNode: defaultNode
|
|
||||||
});
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
context: {
|
context: { ...newState, node: defaultNode, history: newHistory },
|
||||||
...newState,
|
|
||||||
node: defaultNode,
|
|
||||||
history: newHistory
|
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null
|
error: null,
|
||||||
});
|
});
|
||||||
|
logger.debug('navigation-context', '✅ Context switch complete', {
|
||||||
logger.debug('navigation-context', '✅ Context switch completed', {
|
main: newState.main, base: newState.base, nodeId: defaultNode.id,
|
||||||
finalState: {
|
|
||||||
main: newState.main,
|
|
||||||
base: newState.base,
|
|
||||||
nodeId: defaultNode.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('navigation-context', '❌ Failed to switch context:', error);
|
logger.error('navigation-context', '❌ switchContext failed', error);
|
||||||
set({
|
set({ error: error instanceof Error ? error.message : 'Failed to switch context', isLoading: false });
|
||||||
error: error instanceof Error ? error.message : 'Failed to switch context',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -249,14 +192,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
|||||||
const currentState = get().context;
|
const currentState = get().context;
|
||||||
if (currentState.history.currentIndex > 0) {
|
if (currentState.history.currentIndex > 0) {
|
||||||
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
|
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
|
||||||
const node = getCurrentHistoryNode(newHistory);
|
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
|
||||||
set({
|
|
||||||
context: {
|
|
||||||
...currentState,
|
|
||||||
node,
|
|
||||||
history: newHistory
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -264,176 +200,139 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
|
|||||||
const currentState = get().context;
|
const currentState = get().context;
|
||||||
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
|
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
|
||||||
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
|
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
|
||||||
const node = getCurrentHistoryNode(newHistory);
|
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
|
||||||
set({
|
|
||||||
context: {
|
|
||||||
...currentState,
|
|
||||||
node,
|
|
||||||
history: newHistory
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
|
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
|
||||||
try {
|
|
||||||
// Use switchContext instead of direct implementation
|
|
||||||
await get().switchContext({ main }, userDbName, workerDbName);
|
await get().switchContext({ main }, userDbName, workerDbName);
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to set main context:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to set main context',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
|
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
|
||||||
try {
|
|
||||||
// Use switchContext instead of direct implementation
|
|
||||||
await get().switchContext({ base }, userDbName, workerDbName);
|
await get().switchContext({ base }, userDbName, workerDbName);
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to set base context:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to set base context',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
|
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
|
||||||
try {
|
|
||||||
// Use switchContext instead of direct implementation
|
|
||||||
await get().switchContext({ extended }, userDbName, workerDbName);
|
await get().switchContext({ extended }, userDbName, workerDbName);
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to set extended context:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to set extended context',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
navigate: async (nodeId: string, dbName: string) => {
|
navigate: async (nodeId: string, _dbName: string) => {
|
||||||
try {
|
try {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
if (!get()._accessToken) { set({ isLoading: false }); return; }
|
||||||
|
|
||||||
// Check if we already have this node in history
|
|
||||||
const currentState = get().context;
|
const currentState = get().context;
|
||||||
const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
|
const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
// If node exists in history, just navigate to it
|
const newHistory = navigateHistory(currentState.history, existingIndex);
|
||||||
if (existingNodeIndex !== -1) {
|
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false });
|
||||||
logger.debug('navigation', '📍 Navigating to existing node in history', {
|
|
||||||
nodeId,
|
|
||||||
historyIndex: existingNodeIndex,
|
|
||||||
currentIndex: currentState.history.currentIndex
|
|
||||||
});
|
|
||||||
|
|
||||||
const newHistory = navigateHistory(currentState.history, existingNodeIndex);
|
|
||||||
const node = getCurrentHistoryNode(newHistory);
|
|
||||||
|
|
||||||
set({
|
|
||||||
context: {
|
|
||||||
...currentState,
|
|
||||||
node,
|
|
||||||
history: newHistory
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch new node data
|
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||||
const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName);
|
query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`,
|
||||||
if (!nodeData) {
|
});
|
||||||
throw new Error(`Node not found: ${nodeId}`);
|
if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`);
|
||||||
}
|
|
||||||
|
|
||||||
|
const room = rooms[0];
|
||||||
const node: NavigationNode = {
|
const node: NavigationNode = {
|
||||||
id: nodeId,
|
id: room.id,
|
||||||
node_storage_path: nodeData.node_data.node_storage_path || '',
|
node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`,
|
||||||
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
|
label: room.name,
|
||||||
type: nodeData.node_type
|
type: 'workspace',
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.debug('navigation', '📍 Adding new node to history', {
|
|
||||||
nodeId: node.id,
|
|
||||||
type: node.type,
|
|
||||||
node_storage_path: node.node_storage_path
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to history and update state
|
|
||||||
const newHistory = addToHistory(currentState.history, node);
|
const newHistory = addToHistory(currentState.history, node);
|
||||||
set({
|
set({ context: { ...currentState, node, history: newHistory }, isLoading: false });
|
||||||
context: {
|
|
||||||
...currentState,
|
|
||||||
node,
|
|
||||||
history: newHistory
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('navigation', '❌ Failed to navigate:', error);
|
logger.error('navigation', '❌ navigate failed', error);
|
||||||
set({
|
set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false });
|
||||||
error: error instanceof Error ? error.message : 'Failed to navigate',
|
|
||||||
isLoading: false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
|
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
|
||||||
|
if (!isValidNodeType(node.type)) {
|
||||||
|
logger.warn('navigation', `⚠️ navigateToNode called with non-graph type: ${node.type} — navigating anyway`);
|
||||||
|
}
|
||||||
|
await get().navigate(node.id, userDbName || '');
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshNavigationState: async (_userDbName: string | null, _workerDbName: string | null) => {
|
||||||
try {
|
try {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
const currentNode = get().context.node;
|
||||||
if (!isValidNodeType(node.type)) {
|
if (currentNode && get()._accessToken) {
|
||||||
throw new Error(`Invalid node type: ${node.type}`);
|
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||||
}
|
query: `id=eq.${currentNode.id}`,
|
||||||
|
|
||||||
const dbName = getContextDatabase(get().context, userDbName, workerDbName);
|
|
||||||
await get().navigate(node.id, dbName);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('navigation', '❌ Failed to navigate to node:', error);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to navigate to node',
|
|
||||||
isLoading: false
|
|
||||||
});
|
});
|
||||||
|
if (rooms && rooms.length > 0) {
|
||||||
|
const room = rooms[0];
|
||||||
|
set({
|
||||||
|
context: {
|
||||||
|
...get().context,
|
||||||
|
node: {
|
||||||
|
id: room.id,
|
||||||
|
node_storage_path: room.storage_path || currentNode.node_storage_path,
|
||||||
|
label: room.name,
|
||||||
|
type: 'workspace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('navigation', '❌ refreshNavigationState failed', error);
|
||||||
|
set({ error: error instanceof Error ? error.message : 'Failed to refresh', isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => {
|
navigateToNeoNode: async (neoNode: NeoGraphNode) => {
|
||||||
|
const userId = get()._userId;
|
||||||
|
if (!userId || !get()._accessToken) {
|
||||||
|
logger.warn('navigation', '⚠️ navigateToNeoNode called without auth');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
const currentState = get().context;
|
const existing = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
|
||||||
|
query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`,
|
||||||
if (currentState.node) {
|
});
|
||||||
const dbName = getContextDatabase(currentState, userDbName, workerDbName);
|
let room: WhiteboardRoom;
|
||||||
const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName);
|
if (existing && existing.length > 0) {
|
||||||
if (nodeData) {
|
room = existing[0];
|
||||||
|
} else {
|
||||||
|
const storagePath = `${userId}/nodes/${neoNode.neo4j_node_id}.json`;
|
||||||
|
const created = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
|
||||||
|
body: {
|
||||||
|
user_id: userId,
|
||||||
|
name: neoNode.label,
|
||||||
|
context_type: neoNode.node_type.toLowerCase(),
|
||||||
|
is_default: false,
|
||||||
|
storage_path: storagePath,
|
||||||
|
neo4j_node_id: neoNode.neo4j_node_id,
|
||||||
|
neo4j_db_name: neoNode.neo4j_db_name,
|
||||||
|
node_type: neoNode.node_type,
|
||||||
|
},
|
||||||
|
prefer: 'return=representation',
|
||||||
|
single: true,
|
||||||
|
});
|
||||||
|
if (!created) throw new Error('Failed to create whiteboard room for node');
|
||||||
|
room = created;
|
||||||
|
}
|
||||||
const node: NavigationNode = {
|
const node: NavigationNode = {
|
||||||
id: currentState.node.id,
|
id: room.id,
|
||||||
node_storage_path: nodeData.node_data.node_storage_path || '',
|
node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`,
|
||||||
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id,
|
label: room.name,
|
||||||
type: nodeData.node_type
|
type: neoNode.node_type,
|
||||||
};
|
};
|
||||||
set({
|
const currentState = get().context;
|
||||||
context: {
|
const newHistory = addToHistory(currentState.history, node);
|
||||||
...currentState,
|
set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null });
|
||||||
node
|
logger.debug('navigation', '✅ Navigated to Neo4j node', { neoNode });
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ isLoading: false });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('navigation', '❌ Failed to refresh navigation state:', error);
|
logger.error('navigation', '❌ navigateToNeoNode failed', error);
|
||||||
set({
|
set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false });
|
||||||
error: error instanceof Error ? error.message : 'Failed to refresh navigation state',
|
}
|
||||||
isLoading: false
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
|
import {
|
||||||
|
Class,
|
||||||
|
ClassWithRole,
|
||||||
|
ClassWithRelations,
|
||||||
|
Timetable,
|
||||||
|
TimetableWithRelations,
|
||||||
|
TimetableLesson,
|
||||||
|
Lesson,
|
||||||
|
LessonWithRelations,
|
||||||
|
EnrollmentRequest,
|
||||||
|
EnrollmentRequestWithProfile,
|
||||||
|
} from '../types/timetable.types';
|
||||||
|
import { timetableService } from '../services/timetableService';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// State Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TimetableState {
|
||||||
|
// Classes
|
||||||
|
classes: Class[];
|
||||||
|
currentClass: ClassWithRelations | null;
|
||||||
|
myClasses: ClassWithRole[];
|
||||||
|
myTeachingClasses: Class[];
|
||||||
|
classesLoading: boolean;
|
||||||
|
classesError: string | null;
|
||||||
|
|
||||||
|
// Timetables
|
||||||
|
timetables: Timetable[];
|
||||||
|
currentTimetable: TimetableWithRelations | null;
|
||||||
|
timetablesLoading: boolean;
|
||||||
|
timetablesError: string | null;
|
||||||
|
|
||||||
|
// Lessons
|
||||||
|
lessons: Lesson[];
|
||||||
|
currentLesson: LessonWithRelations | null;
|
||||||
|
lessonsLoading: boolean;
|
||||||
|
lessonsError: string | null;
|
||||||
|
|
||||||
|
// Enrollment Requests
|
||||||
|
enrollmentRequests: EnrollmentRequestWithProfile[];
|
||||||
|
enrollmentRequestsLoading: boolean;
|
||||||
|
enrollmentRequestsError: string | null;
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
totalCount: number;
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
filterSubject: string | null;
|
||||||
|
filterSchoolYear: string | null;
|
||||||
|
filterAcademicTerm: string | null;
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimetableActions {
|
||||||
|
// Class Actions
|
||||||
|
fetchClasses: (params?: {
|
||||||
|
subject?: string;
|
||||||
|
schoolYear?: string;
|
||||||
|
academicTerm?: string;
|
||||||
|
search?: string;
|
||||||
|
skip?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
fetchClass: (classId: string) => Promise<void>;
|
||||||
|
createClass: (data: {
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
schoolYear: string;
|
||||||
|
academicTerm: string;
|
||||||
|
description?: string;
|
||||||
|
}) => Promise<Class>;
|
||||||
|
updateClass: (classId: string, data: Partial<Class>) => Promise<void>;
|
||||||
|
deleteClass: (classId: string) => Promise<void>;
|
||||||
|
fetchMyClasses: () => Promise<void>;
|
||||||
|
fetchMyTeachingClasses: () => Promise<void>;
|
||||||
|
clearCurrentClass: () => void;
|
||||||
|
|
||||||
|
// Class Teacher Actions
|
||||||
|
addTeacherToClass: (classId: string, teacherId: string, isPrimary?: boolean) => Promise<void>;
|
||||||
|
removeTeacherFromClass: (classId: string, teacherId: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Class Student Actions
|
||||||
|
addStudentToClass: (classId: string, studentId: string) => Promise<void>;
|
||||||
|
removeStudentFromClass: (classId: string, studentId: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Enrollment Request Actions
|
||||||
|
fetchEnrollmentRequests: (classId: string) => Promise<void>;
|
||||||
|
requestEnrollment: (classId: string, message?: string) => Promise<void>;
|
||||||
|
respondToEnrollmentRequest: (requestId: string, status: 'approved' | 'rejected', responseMessage?: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Timetable Actions
|
||||||
|
fetchTimetables: (params?: {
|
||||||
|
classId?: string;
|
||||||
|
type?: 'adhoc' | 'recurring';
|
||||||
|
isActive?: boolean;
|
||||||
|
skip?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
fetchTimetable: (timetableId: string) => Promise<void>;
|
||||||
|
createTimetable: (data: {
|
||||||
|
classId: string;
|
||||||
|
name: string;
|
||||||
|
type: 'adhoc' | 'recurring';
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
recurrenceRule?: string;
|
||||||
|
}) => Promise<Timetable>;
|
||||||
|
updateTimetable: (timetableId: string, data: Partial<Timetable>) => Promise<void>;
|
||||||
|
deleteTimetable: (timetableId: string) => Promise<void>;
|
||||||
|
fetchMyTimetables: () => Promise<void>;
|
||||||
|
fetchMyTeachingTimetables: () => Promise<void>;
|
||||||
|
clearCurrentTimetable: () => void;
|
||||||
|
|
||||||
|
// Timetable Teacher Actions
|
||||||
|
addTeacherToTimetable: (timetableId: string, teacherId: string, isPrimary?: boolean) => Promise<void>;
|
||||||
|
removeTeacherFromTimetable: (timetableId: string, teacherId: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Timetable Lesson Actions
|
||||||
|
addTimetableLesson: (timetableId: string, data: {
|
||||||
|
dayOfWeek: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
room?: string;
|
||||||
|
maxStudents?: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
updateTimetableLesson: (timetableId: string, lessonId: string, data: Partial<TimetableLesson>) => Promise<void>;
|
||||||
|
removeTimetableLesson: (timetableId: string, lessonId: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Lesson Actions
|
||||||
|
fetchLessons: (params?: {
|
||||||
|
timetableId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
status?: 'scheduled' | 'completed' | 'cancelled';
|
||||||
|
skip?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
fetchLesson: (lessonId: string) => Promise<void>;
|
||||||
|
generateLessons: (timetableId: string, startDate: string, endDate: string) => Promise<void>;
|
||||||
|
cancelLesson: (lessonId: string, reason?: string) => Promise<void>;
|
||||||
|
clearCurrentLesson: () => void;
|
||||||
|
|
||||||
|
// Filter Actions
|
||||||
|
setFilterSubject: (subject: string | null) => void;
|
||||||
|
setFilterSchoolYear: (schoolYear: string | null) => void;
|
||||||
|
setFilterAcademicTerm: (academicTerm: string | null) => void;
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
setPageSize: (size: number) => void;
|
||||||
|
clearFilters: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Initial State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const initialState: TimetableState = {
|
||||||
|
classes: [],
|
||||||
|
currentClass: null,
|
||||||
|
myClasses: [],
|
||||||
|
myTeachingClasses: [],
|
||||||
|
classesLoading: false,
|
||||||
|
classesError: null,
|
||||||
|
|
||||||
|
timetables: [],
|
||||||
|
currentTimetable: null,
|
||||||
|
timetablesLoading: false,
|
||||||
|
timetablesError: null,
|
||||||
|
|
||||||
|
lessons: [],
|
||||||
|
currentLesson: null,
|
||||||
|
lessonsLoading: false,
|
||||||
|
lessonsError: null,
|
||||||
|
|
||||||
|
enrollmentRequests: [],
|
||||||
|
enrollmentRequestsLoading: false,
|
||||||
|
enrollmentRequestsError: null,
|
||||||
|
|
||||||
|
totalCount: 0,
|
||||||
|
currentPage: 0,
|
||||||
|
pageSize: 20,
|
||||||
|
|
||||||
|
filterSubject: null,
|
||||||
|
filterSchoolYear: null,
|
||||||
|
filterAcademicTerm: null,
|
||||||
|
searchQuery: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const useTimetableStore = create<TimetableState & TimetableActions>()(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Class Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fetchClasses: async (params = {}) => {
|
||||||
|
set({ classesLoading: true, classesError: null });
|
||||||
|
try {
|
||||||
|
const response = await timetableService.getClasses(params);
|
||||||
|
set({
|
||||||
|
classes: response.items,
|
||||||
|
totalCount: response.total,
|
||||||
|
classesLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch classes:', error);
|
||||||
|
set({
|
||||||
|
classesError: error instanceof Error ? error.message : 'Failed to fetch classes',
|
||||||
|
classesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchClass: async (classId: string) => {
|
||||||
|
set({ classesLoading: true, classesError: null });
|
||||||
|
try {
|
||||||
|
const classData = await timetableService.getClass(classId);
|
||||||
|
set({ currentClass: classData, classesLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch class:', error);
|
||||||
|
set({
|
||||||
|
classesError: error instanceof Error ? error.message : 'Failed to fetch class',
|
||||||
|
classesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createClass: async (data) => {
|
||||||
|
const newClass = await timetableService.createClass(data);
|
||||||
|
set((state) => ({
|
||||||
|
classes: [newClass, ...state.classes],
|
||||||
|
}));
|
||||||
|
return newClass;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateClass: async (classId, data) => {
|
||||||
|
const updatedClass = await timetableService.updateClass(classId, data);
|
||||||
|
set((state) => ({
|
||||||
|
classes: state.classes.map((c) => (c.id === classId ? updatedClass : c)),
|
||||||
|
currentClass: state.currentClass?.id === classId ? { ...state.currentClass, ...updatedClass } : state.currentClass,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteClass: async (classId) => {
|
||||||
|
await timetableService.deleteClass(classId);
|
||||||
|
set((state) => ({
|
||||||
|
classes: state.classes.filter((c) => c.id !== classId),
|
||||||
|
currentClass: state.currentClass?.id === classId ? null : state.currentClass,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchMyClasses: async () => {
|
||||||
|
set({ classesLoading: true, classesError: null });
|
||||||
|
try {
|
||||||
|
const classes = await timetableService.getMyClasses();
|
||||||
|
set({ myClasses: classes, classesLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch my classes:', error);
|
||||||
|
set({
|
||||||
|
classesError: error instanceof Error ? error.message : 'Failed to fetch my classes',
|
||||||
|
classesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchMyTeachingClasses: async () => {
|
||||||
|
set({ classesLoading: true, classesError: null });
|
||||||
|
try {
|
||||||
|
const classes = await timetableService.getMyTeachingClasses();
|
||||||
|
set({ myTeachingClasses: classes, classesLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch my teaching classes:', error);
|
||||||
|
set({
|
||||||
|
classesError: error instanceof Error ? error.message : 'Failed to fetch my teaching classes',
|
||||||
|
classesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCurrentClass: () => set({ currentClass: null }),
|
||||||
|
|
||||||
|
addTeacherToClass: async (classId, teacherId, isPrimary = false) => {
|
||||||
|
await timetableService.addTeacherToClass(classId, teacherId, isPrimary);
|
||||||
|
await get().fetchClass(classId);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTeacherFromClass: async (classId, teacherId) => {
|
||||||
|
await timetableService.removeTeacherFromClass(classId, teacherId);
|
||||||
|
await get().fetchClass(classId);
|
||||||
|
},
|
||||||
|
|
||||||
|
addStudentToClass: async (classId, studentId) => {
|
||||||
|
await timetableService.addStudentToClass(classId, studentId);
|
||||||
|
await get().fetchClass(classId);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeStudentFromClass: async (classId, studentId) => {
|
||||||
|
await timetableService.removeStudentFromClass(classId, studentId);
|
||||||
|
await get().fetchClass(classId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enrollment Request Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fetchEnrollmentRequests: async (classId) => {
|
||||||
|
set({ enrollmentRequestsLoading: true, enrollmentRequestsError: null });
|
||||||
|
try {
|
||||||
|
const requests = await timetableService.getEnrollmentRequests(classId);
|
||||||
|
set({ enrollmentRequests: requests, enrollmentRequestsLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch enrollment requests:', error);
|
||||||
|
set({
|
||||||
|
enrollmentRequestsError: error instanceof Error ? error.message : 'Failed to fetch enrollment requests',
|
||||||
|
enrollmentRequestsLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
requestEnrollment: async (classId, message) => {
|
||||||
|
await timetableService.requestEnrollment(classId, message);
|
||||||
|
},
|
||||||
|
|
||||||
|
respondToEnrollmentRequest: async (requestId, status, responseMessage) => {
|
||||||
|
await timetableService.respondToEnrollmentRequest(requestId, status, responseMessage);
|
||||||
|
await get().fetchEnrollmentRequests(get().currentClass?.id || '');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timetable Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fetchTimetables: async (params = {}) => {
|
||||||
|
set({ timetablesLoading: true, timetablesError: null });
|
||||||
|
try {
|
||||||
|
const response = await timetableService.getTimetables(params);
|
||||||
|
set({
|
||||||
|
timetables: response.items,
|
||||||
|
totalCount: response.total,
|
||||||
|
timetablesLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch timetables:', error);
|
||||||
|
set({
|
||||||
|
timetablesError: error instanceof Error ? error.message : 'Failed to fetch timetables',
|
||||||
|
timetablesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchTimetable: async (timetableId) => {
|
||||||
|
set({ timetablesLoading: true, timetablesError: null });
|
||||||
|
try {
|
||||||
|
const timetable = await timetableService.getTimetable(timetableId);
|
||||||
|
set({ currentTimetable: timetable, timetablesLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch timetable:', error);
|
||||||
|
set({
|
||||||
|
timetablesError: error instanceof Error ? error.message : 'Failed to fetch timetable',
|
||||||
|
timetablesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createTimetable: async (data) => {
|
||||||
|
const newTimetable = await timetableService.createTimetable(data);
|
||||||
|
set((state) => ({
|
||||||
|
timetables: [newTimetable, ...state.timetables],
|
||||||
|
}));
|
||||||
|
return newTimetable;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTimetable: async (timetableId, data) => {
|
||||||
|
const updatedTimetable = await timetableService.updateTimetable(timetableId, data);
|
||||||
|
set((state) => ({
|
||||||
|
timetables: state.timetables.map((t) => (t.id === timetableId ? updatedTimetable : t)),
|
||||||
|
currentTimetable: state.currentTimetable?.id === timetableId ? { ...state.currentTimetable, ...updatedTimetable } : state.currentTimetable,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTimetable: async (timetableId) => {
|
||||||
|
await timetableService.deleteTimetable(timetableId);
|
||||||
|
set((state) => ({
|
||||||
|
timetables: state.timetables.filter((t) => t.id !== timetableId),
|
||||||
|
currentTimetable: state.currentTimetable?.id === timetableId ? null : state.currentTimetable,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchMyTimetables: async () => {
|
||||||
|
set({ timetablesLoading: true, timetablesError: null });
|
||||||
|
try {
|
||||||
|
const timetables = await timetableService.getMyTimetables();
|
||||||
|
set({ timetables, timetablesLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch my timetables:', error);
|
||||||
|
set({
|
||||||
|
timetablesError: error instanceof Error ? error.message : 'Failed to fetch my timetables',
|
||||||
|
timetablesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchMyTeachingTimetables: async () => {
|
||||||
|
set({ timetablesLoading: true, timetablesError: null });
|
||||||
|
try {
|
||||||
|
const timetables = await timetableService.getMyTeachingTimetables();
|
||||||
|
set({ timetables, timetablesLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch my teaching timetables:', error);
|
||||||
|
set({
|
||||||
|
timetablesError: error instanceof Error ? error.message : 'Failed to fetch my teaching timetables',
|
||||||
|
timetablesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCurrentTimetable: () => set({ currentTimetable: null }),
|
||||||
|
|
||||||
|
addTeacherToTimetable: async (timetableId, teacherId, isPrimary = false) => {
|
||||||
|
await timetableService.addTeacherToTimetable(timetableId, teacherId, isPrimary);
|
||||||
|
await get().fetchTimetable(timetableId);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTeacherFromTimetable: async (timetableId, teacherId) => {
|
||||||
|
await timetableService.removeTeacherFromTimetable(timetableId, teacherId);
|
||||||
|
await get().fetchTimetable(timetableId);
|
||||||
|
},
|
||||||
|
|
||||||
|
addTimetableLesson: async (timetableId, data) => {
|
||||||
|
await timetableService.addTimetableLesson(timetableId, data);
|
||||||
|
await get().fetchTimetable(timetableId);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTimetableLesson: async (timetableId, lessonId, data) => {
|
||||||
|
await timetableService.updateTimetableLesson(timetableId, lessonId, data);
|
||||||
|
await get().fetchTimetable(timetableId);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTimetableLesson: async (timetableId, lessonId) => {
|
||||||
|
await timetableService.removeTimetableLesson(timetableId, lessonId);
|
||||||
|
await get().fetchTimetable(timetableId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lesson Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fetchLessons: async (params = {}) => {
|
||||||
|
set({ lessonsLoading: true, lessonsError: null });
|
||||||
|
try {
|
||||||
|
const response = await timetableService.getLessons(params);
|
||||||
|
set({
|
||||||
|
lessons: response.items,
|
||||||
|
totalCount: response.total,
|
||||||
|
lessonsLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch lessons:', error);
|
||||||
|
set({
|
||||||
|
lessonsError: error instanceof Error ? error.message : 'Failed to fetch lessons',
|
||||||
|
lessonsLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchLesson: async (lessonId) => {
|
||||||
|
set({ lessonsLoading: true, lessonsError: null });
|
||||||
|
try {
|
||||||
|
const lesson = await timetableService.getLesson(lessonId);
|
||||||
|
set({ currentLesson: lesson, lessonsLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch lesson:', error);
|
||||||
|
set({
|
||||||
|
lessonsError: error instanceof Error ? error.message : 'Failed to fetch lesson',
|
||||||
|
lessonsLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generateLessons: async (timetableId, startDate, endDate) => {
|
||||||
|
set({ lessonsLoading: true, lessonsError: null });
|
||||||
|
try {
|
||||||
|
await timetableService.generateLessons(timetableId, startDate, endDate);
|
||||||
|
await get().fetchLessons({ timetableId });
|
||||||
|
set({ lessonsLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate lessons:', error);
|
||||||
|
set({
|
||||||
|
lessonsError: error instanceof Error ? error.message : 'Failed to generate lessons',
|
||||||
|
lessonsLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelLesson: async (lessonId, reason) => {
|
||||||
|
await timetableService.cancelLesson(lessonId, reason);
|
||||||
|
await get().fetchLesson(lessonId);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCurrentLesson: () => set({ currentLesson: null }),
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Filter Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
setFilterSubject: (subject) => set({ filterSubject: subject, currentPage: 0 }),
|
||||||
|
setFilterSchoolYear: (schoolYear) => set({ filterSchoolYear: schoolYear, currentPage: 0 }),
|
||||||
|
setFilterAcademicTerm: (academicTerm) => set({ filterAcademicTerm: academicTerm, currentPage: 0 }),
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query, currentPage: 0 }),
|
||||||
|
setPage: (page) => set({ currentPage: page }),
|
||||||
|
setPageSize: (size) => set({ pageSize: size, currentPage: 0 }),
|
||||||
|
|
||||||
|
clearFilters: () =>
|
||||||
|
set({
|
||||||
|
filterSubject: null,
|
||||||
|
filterSchoolYear: null,
|
||||||
|
filterAcademicTerm: null,
|
||||||
|
searchQuery: '',
|
||||||
|
currentPage: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'timetable-store',
|
||||||
|
partialize: (state) => ({
|
||||||
|
filterSubject: state.filterSubject,
|
||||||
|
filterSchoolYear: state.filterSchoolYear,
|
||||||
|
filterAcademicTerm: state.filterAcademicTerm,
|
||||||
|
pageSize: state.pageSize,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ name: 'TimetableStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useTimetableStore;
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface TranscriptionSegment {
|
||||||
|
text: string;
|
||||||
|
isFinal: boolean;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionSession {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
title: string | null;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
timetable_period_id: string | null;
|
||||||
|
timetable_event_type: string | null;
|
||||||
|
timetable_event_label: string | null;
|
||||||
|
auto_tagged: boolean;
|
||||||
|
word_count: number;
|
||||||
|
segment_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerSegment {
|
||||||
|
text: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimetablePeriod {
|
||||||
|
period_id: string | null;
|
||||||
|
event_type: string | null;
|
||||||
|
event_label: string | null;
|
||||||
|
start_time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMConfig {
|
||||||
|
provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google';
|
||||||
|
model: string;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl?: string; // for Ollama: e.g. https://ollama.kevlarai.com
|
||||||
|
whisperModel?: string; // faster-whisper model size sent to WhisperLive
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportFormat = 'srt' | 'txt' | 'json';
|
||||||
|
|
||||||
|
export interface KeywordWatch {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
keyword: string;
|
||||||
|
match_type: string;
|
||||||
|
action: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeywordMatch {
|
||||||
|
keyword: string;
|
||||||
|
watch_id: string | null;
|
||||||
|
segment_text: string;
|
||||||
|
elapsed_seconds: number;
|
||||||
|
matched_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LLM_CONFIG_STORAGE_KEY = 'cc_llm_config';
|
||||||
|
|
||||||
|
function loadLLMConfig(): LLMConfig {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LLM_CONFIG_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load LLM config from localStorage:', e);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: 'openai',
|
||||||
|
model: '',
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: '',
|
||||||
|
whisperModel: 'large-v3',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLLMConfig(config: LLMConfig): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LLM_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save LLM Config to localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscriptionState {
|
||||||
|
// Session state
|
||||||
|
isRecording: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
activeSession: TranscriptionSession | null;
|
||||||
|
|
||||||
|
// Live feed
|
||||||
|
completedSegments: TranscriptionSegment[]; // segments that scrolled off the server window (archived)
|
||||||
|
serverWindow: ServerSegment[]; // the current server-provided segment window (last N)
|
||||||
|
currentSegment: TranscriptionSegment | null; // the live (last) segment if still being refined
|
||||||
|
|
||||||
|
// Canvas event buffer (flushed to API every 5s)
|
||||||
|
pendingCanvasEvents: any[];
|
||||||
|
|
||||||
|
// Timetable context
|
||||||
|
timetableContext: TimetablePeriod | null;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
wordCount: number;
|
||||||
|
elapsedSeconds: number;
|
||||||
|
|
||||||
|
// LLM config (stored in localStorage only)
|
||||||
|
llmConfig: LLMConfig;
|
||||||
|
|
||||||
|
// Summary state
|
||||||
|
summaryText: string | null;
|
||||||
|
isGeneratingSummary: boolean;
|
||||||
|
summaryError: string | null;
|
||||||
|
|
||||||
|
// Export state
|
||||||
|
isExporting: boolean;
|
||||||
|
exportError: string | null;
|
||||||
|
|
||||||
|
// Keyword state
|
||||||
|
keywordWatches: KeywordWatch[];
|
||||||
|
keywordMatches: KeywordMatch[];
|
||||||
|
|
||||||
|
// Auth (set by panel via setAuthInfo after SIGNED_IN)
|
||||||
|
_accessToken: string | null;
|
||||||
|
_userId: string | null;
|
||||||
|
setAuthInfo: (token: string | null, userId: string | null) => void;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
startSession: (timetableTag?: TimetablePeriod) => Promise<void>;
|
||||||
|
stopSession: () => Promise<void>;
|
||||||
|
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void;
|
||||||
|
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>;
|
||||||
|
resetSession: () => void;
|
||||||
|
tickElapsed: () => void;
|
||||||
|
addCanvasEvent: (event: any) => void;
|
||||||
|
flushCanvasEvents: () => Promise<void>;
|
||||||
|
loadSessions: () => Promise<TranscriptionSession[]>;
|
||||||
|
setTimetableContext: (context: TimetablePeriod | null) => void;
|
||||||
|
|
||||||
|
// LLM config actions
|
||||||
|
setLLMConfig: (config: Partial<LLMConfig>) => void;
|
||||||
|
getLLMConfig: () => LLMConfig;
|
||||||
|
|
||||||
|
// Summary actions
|
||||||
|
setSummaryText: (text: string | null) => void;
|
||||||
|
setIsGeneratingSummary: (generating: boolean) => void;
|
||||||
|
setSummaryError: (error: string | null) => void;
|
||||||
|
|
||||||
|
// Export actions
|
||||||
|
exportSession: (sessionId: string, format: ExportFormat) => Promise<void>;
|
||||||
|
setExportError: (error: string | null) => void;
|
||||||
|
|
||||||
|
// Keyword actions
|
||||||
|
loadKeywordWatches: () => Promise<void>;
|
||||||
|
addKeywordWatch: (keyword: string) => Promise<void>;
|
||||||
|
deleteKeywordWatch: (watchId: string) => Promise<void>;
|
||||||
|
checkSegmentForKeywords: (text: string, elapsedSeconds: number) => Promise<void>;
|
||||||
|
clearKeywordMatches: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
|
||||||
|
// Direct PostgREST fetch — uses stored _accessToken, no GoTrueClient lock.
|
||||||
|
const pgFetch = async <T = any>(
|
||||||
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||||
|
table: string,
|
||||||
|
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
|
||||||
|
): Promise<T | null> => {
|
||||||
|
const token = get()._accessToken;
|
||||||
|
if (!token) throw new Error('pgFetch: no access token');
|
||||||
|
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (options.prefer) headers['Prefer'] = options.prefer;
|
||||||
|
if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`PostgREST ${res.status}: ${err}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording: false,
|
||||||
|
isConnecting: false,
|
||||||
|
activeSession: null,
|
||||||
|
_accessToken: null,
|
||||||
|
_userId: null,
|
||||||
|
completedSegments: [],
|
||||||
|
serverWindow: [],
|
||||||
|
currentSegment: null,
|
||||||
|
pendingCanvasEvents: [],
|
||||||
|
timetableContext: null,
|
||||||
|
wordCount: 0,
|
||||||
|
elapsedSeconds: 0,
|
||||||
|
|
||||||
|
// LLM config initialized from localStorage
|
||||||
|
llmConfig: loadLLMConfig(),
|
||||||
|
|
||||||
|
// Summary state
|
||||||
|
summaryText: null,
|
||||||
|
isGeneratingSummary: false,
|
||||||
|
summaryError: null,
|
||||||
|
|
||||||
|
// Export state
|
||||||
|
isExporting: false,
|
||||||
|
exportError: null,
|
||||||
|
|
||||||
|
// Keyword state
|
||||||
|
keywordWatches: [],
|
||||||
|
keywordMatches: [],
|
||||||
|
|
||||||
|
setTimetableContext: (context) => {
|
||||||
|
set({ timetableContext: context });
|
||||||
|
},
|
||||||
|
|
||||||
|
setAuthInfo: (token: string | null, userId: string | null) => {
|
||||||
|
set({ _accessToken: token, _userId: userId });
|
||||||
|
},
|
||||||
|
|
||||||
|
startSession: async (timetableTag?: TimetablePeriod) => {
|
||||||
|
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { _userId: userId } = get();
|
||||||
|
if (!userId) {
|
||||||
|
console.error('No authenticated user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = {
|
||||||
|
user_id: userId,
|
||||||
|
title: timetableTag?.event_label || 'Untitled Session',
|
||||||
|
canvas_type: 'teaching-canvas',
|
||||||
|
timetable_period_id: timetableTag?.period_id || null,
|
||||||
|
timetable_event_type: timetableTag?.event_type || null,
|
||||||
|
timetable_event_label: timetableTag?.event_label || null,
|
||||||
|
auto_tagged: !!timetableTag,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await pgFetch<TranscriptionSession>('POST', 'transcription_sessions', {
|
||||||
|
body: sessionData,
|
||||||
|
prefer: 'return=representation',
|
||||||
|
single: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
console.error('Failed to create session: no data returned');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ activeSession: data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting session:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stopSession: async () => {
|
||||||
|
const { activeSession, currentSegment, completedSegments } = get();
|
||||||
|
|
||||||
|
// The live segment (currentSegment) was never added to completedSegments — flush it now.
|
||||||
|
let newCompleted = [...completedSegments];
|
||||||
|
if (currentSegment && currentSegment.text.trim()) {
|
||||||
|
const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5);
|
||||||
|
if (!alreadyIn) {
|
||||||
|
const idx = newCompleted.length;
|
||||||
|
newCompleted.push({ ...currentSegment, isFinal: true });
|
||||||
|
if (activeSession) {
|
||||||
|
pgFetch('POST', 'transcription_segments', {
|
||||||
|
body: {
|
||||||
|
session_id: activeSession.id,
|
||||||
|
sequence_index: idx,
|
||||||
|
text: currentSegment.text,
|
||||||
|
start_seconds: currentSegment.start,
|
||||||
|
end_seconds: currentSegment.end,
|
||||||
|
is_final: true,
|
||||||
|
},
|
||||||
|
}).catch(err => console.error('Failed to save live segment on stop:', err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalWordCount = newCompleted.reduce(
|
||||||
|
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeSession) {
|
||||||
|
try {
|
||||||
|
await pgFetch('PATCH', 'transcription_sessions', {
|
||||||
|
query: `id=eq.${activeSession.id}`,
|
||||||
|
body: {
|
||||||
|
ended_at: new Date().toISOString(),
|
||||||
|
word_count: finalWordCount,
|
||||||
|
segment_count: newCompleted.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to end session:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
isRecording: false,
|
||||||
|
isConnecting: false,
|
||||||
|
activeSession: null,
|
||||||
|
completedSegments: newCompleted,
|
||||||
|
serverWindow: [],
|
||||||
|
currentSegment: null,
|
||||||
|
wordCount: finalWordCount,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => {
|
||||||
|
const { completedSegments, activeSession } = get();
|
||||||
|
|
||||||
|
if (segments.length === 0) return;
|
||||||
|
|
||||||
|
// The server marks every finalized segment with completed=true and the live
|
||||||
|
// one with completed=false. Rather than relying on window-scroll detection
|
||||||
|
// (which can miss segments when the server creates several at once), we
|
||||||
|
// directly merge every completed segment from this message into the store.
|
||||||
|
// This guarantees no gaps: any segment the server says is complete is captured
|
||||||
|
// immediately, regardless of how many were created since the last message.
|
||||||
|
const serverCompleted = isLastLive ? segments.slice(0, -1) : segments;
|
||||||
|
|
||||||
|
let newCompleted = [...completedSegments];
|
||||||
|
const toSave: Array<{ seg: ServerSegment; idx: number }> = [];
|
||||||
|
|
||||||
|
for (const seg of serverCompleted) {
|
||||||
|
if (!seg.text.trim()) continue;
|
||||||
|
const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5);
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
// Server refined an existing segment — update text and end time in place.
|
||||||
|
newCompleted[existingIdx] = {
|
||||||
|
...newCompleted[existingIdx],
|
||||||
|
text: seg.text,
|
||||||
|
end: seg.end,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const newIdx = newCompleted.length;
|
||||||
|
newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end });
|
||||||
|
toSave.push({ seg, idx: newIdx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep sorted by start time so display order is always correct.
|
||||||
|
newCompleted.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
// Persist and keyword-check only truly new segments.
|
||||||
|
if (toSave.length > 0) {
|
||||||
|
const elapsed = get().elapsedSeconds;
|
||||||
|
for (const { seg, idx } of toSave) {
|
||||||
|
if (activeSession) {
|
||||||
|
pgFetch('POST', 'transcription_segments', {
|
||||||
|
body: {
|
||||||
|
session_id: activeSession.id,
|
||||||
|
sequence_index: idx,
|
||||||
|
text: seg.text,
|
||||||
|
start_seconds: seg.start,
|
||||||
|
end_seconds: seg.end,
|
||||||
|
is_final: true,
|
||||||
|
},
|
||||||
|
}).catch(err => console.error('Failed to save segment:', err));
|
||||||
|
}
|
||||||
|
get().checkSegmentForKeywords(seg.text, elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeg = segments[segments.length - 1];
|
||||||
|
const newCurrentSegment: TranscriptionSegment | null = isLastLive
|
||||||
|
? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const newWordCount = newCompleted.reduce(
|
||||||
|
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
set({
|
||||||
|
serverWindow: segments,
|
||||||
|
completedSegments: newCompleted,
|
||||||
|
currentSegment: newCurrentSegment,
|
||||||
|
wordCount: newWordCount,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
|
||||||
|
const { completedSegments, currentSegment, activeSession } = get();
|
||||||
|
|
||||||
|
if (isFinal) {
|
||||||
|
// Deduplicate by start time: if a segment with this start already exists, update it
|
||||||
|
// rather than appending. This prevents doubles when the stability timer fires and
|
||||||
|
// the segment later appears in the server's finalized list with a slightly extended end.
|
||||||
|
const existingIdx = completedSegments.findIndex(
|
||||||
|
(s) => Math.abs(s.start - metadata.start) < 0.5
|
||||||
|
);
|
||||||
|
|
||||||
|
let newCompleted: TranscriptionSegment[];
|
||||||
|
let isNew: boolean;
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
newCompleted = completedSegments.map((s, i) =>
|
||||||
|
i === existingIdx ? { text, isFinal: true, ...metadata } : s
|
||||||
|
);
|
||||||
|
isNew = false;
|
||||||
|
} else {
|
||||||
|
newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
|
||||||
|
isNew = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWordCount = newCompleted.reduce(
|
||||||
|
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount });
|
||||||
|
|
||||||
|
if (isNew && activeSession) {
|
||||||
|
try {
|
||||||
|
await pgFetch('POST', 'transcription_segments', {
|
||||||
|
body: {
|
||||||
|
session_id: activeSession.id,
|
||||||
|
sequence_index: newCompleted.length - 1,
|
||||||
|
text,
|
||||||
|
start_seconds: metadata.start,
|
||||||
|
end_seconds: metadata.end,
|
||||||
|
is_final: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save segment:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// In-progress segment. If the start time jumped to a new position, the previous
|
||||||
|
// live segment is done — auto-commit it before switching.
|
||||||
|
if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) {
|
||||||
|
const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }];
|
||||||
|
const autoWordCount = autoCompleted.reduce(
|
||||||
|
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
set({ completedSegments: autoCompleted, wordCount: autoWordCount });
|
||||||
|
if (activeSession) {
|
||||||
|
pgFetch('POST', 'transcription_segments', {
|
||||||
|
body: {
|
||||||
|
session_id: activeSession.id,
|
||||||
|
sequence_index: autoCompleted.length - 1,
|
||||||
|
text: currentSegment.text,
|
||||||
|
start_seconds: currentSegment.start,
|
||||||
|
end_seconds: currentSegment.end,
|
||||||
|
is_final: true,
|
||||||
|
},
|
||||||
|
}).catch(err => console.error('Failed to save auto-committed segment:', err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ currentSegment: { text, isFinal: false, ...metadata } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetSession: () => {
|
||||||
|
set({
|
||||||
|
isRecording: false,
|
||||||
|
isConnecting: false,
|
||||||
|
completedSegments: [],
|
||||||
|
serverWindow: [],
|
||||||
|
currentSegment: null,
|
||||||
|
wordCount: 0,
|
||||||
|
elapsedSeconds: 0,
|
||||||
|
activeSession: null,
|
||||||
|
pendingCanvasEvents: [],
|
||||||
|
timetableContext: null,
|
||||||
|
keywordMatches: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
tickElapsed: () => {
|
||||||
|
set((state) => ({ elapsedSeconds: state.elapsedSeconds + 1 }));
|
||||||
|
},
|
||||||
|
|
||||||
|
addCanvasEvent: (event) => {
|
||||||
|
set((state) => ({
|
||||||
|
pendingCanvasEvents: [...state.pendingCanvasEvents, event],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
flushCanvasEvents: async () => {
|
||||||
|
const { pendingCanvasEvents, activeSession } = get();
|
||||||
|
|
||||||
|
if (pendingCanvasEvents.length === 0) return;
|
||||||
|
|
||||||
|
const eventsToFlush = [...pendingCanvasEvents];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const event of eventsToFlush) {
|
||||||
|
await pgFetch('POST', 'canvas_events', {
|
||||||
|
body: {
|
||||||
|
session_id: activeSession?.id || null,
|
||||||
|
user_id: get()._userId || '',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
session_elapsed_seconds: event.sessionElapsedSeconds || null,
|
||||||
|
event_type: event.eventType,
|
||||||
|
event_payload: event.payload || {},
|
||||||
|
canvas_snapshot_url: event.snapshotUrl || null,
|
||||||
|
tldraw_page_id: event.pageId || null,
|
||||||
|
tldraw_shape_ids: event.shapeIds || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ pendingCanvasEvents: [] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to flush canvas events:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSessions: async (): Promise<TranscriptionSession[]> => {
|
||||||
|
try {
|
||||||
|
const { _userId: userId } = get();
|
||||||
|
if (!userId) return [];
|
||||||
|
|
||||||
|
const data = await pgFetch<TranscriptionSession[]>('GET', 'transcription_sessions', {
|
||||||
|
query: `user_id=eq.${userId}&order=started_at.desc&limit=50&select=*`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading sessions:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// LLM config actions - persist to localStorage only
|
||||||
|
setLLMConfig: (partialConfig: Partial<LLMConfig>) => {
|
||||||
|
const current = get().llmConfig;
|
||||||
|
const updated = { ...current, ...partialConfig };
|
||||||
|
saveLLMConfig(updated);
|
||||||
|
set({ llmConfig: updated });
|
||||||
|
},
|
||||||
|
|
||||||
|
getLLMConfig: (): LLMConfig => {
|
||||||
|
return get().llmConfig;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Summary actions
|
||||||
|
setSummaryText: (text: string | null) => {
|
||||||
|
set({ summaryText: text });
|
||||||
|
},
|
||||||
|
|
||||||
|
setIsGeneratingSummary: (generating: boolean) => {
|
||||||
|
set({ isGeneratingSummary: generating });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSummaryError: (error: string | null) => {
|
||||||
|
set({ summaryError: error });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export actions
|
||||||
|
exportSession: async (sessionId: string, format: ExportFormat) => {
|
||||||
|
set({ isExporting: true, exportError: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ format }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.detail || errorData?.error || `Export failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filename from Content-Disposition header or use default
|
||||||
|
const disposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = `transcription-export.${format}`;
|
||||||
|
if (disposition) {
|
||||||
|
const match = disposition.match(/filename[*]?=['"\s]*([^;\s]*)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
filename = match[1].replace(/["'\\]/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger browser download
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export session:', error);
|
||||||
|
set({ exportError: error instanceof Error ? error.message : 'Failed to export session' });
|
||||||
|
} finally {
|
||||||
|
set({ isExporting: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setExportError: (error: string | null) => {
|
||||||
|
set({ exportError: error });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadKeywordWatches: async () => {
|
||||||
|
try {
|
||||||
|
const { _accessToken: token } = get();
|
||||||
|
if (!token) return;
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const watches = await response.json();
|
||||||
|
set({ keywordWatches: watches });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load keyword watches:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeywordWatch: async (keyword: string) => {
|
||||||
|
try {
|
||||||
|
const { _accessToken: token, _userId: userId } = get();
|
||||||
|
if (!token || !userId) return;
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: userId,
|
||||||
|
keyword: keyword.trim(),
|
||||||
|
match_type: 'contains',
|
||||||
|
action: 'alert',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const newWatch = await response.json();
|
||||||
|
set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add keyword watch:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteKeywordWatch: async (watchId: string) => {
|
||||||
|
try {
|
||||||
|
const { _accessToken: token } = get();
|
||||||
|
if (!token) return;
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete keyword watch:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => {
|
||||||
|
const { keywordWatches, activeSession } = get();
|
||||||
|
if (keywordWatches.length === 0) return;
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
const matches: KeywordMatch[] = [];
|
||||||
|
|
||||||
|
for (const watch of keywordWatches) {
|
||||||
|
if (!watch.is_active) continue;
|
||||||
|
const lowerKeyword = watch.keyword.toLowerCase();
|
||||||
|
const matched =
|
||||||
|
watch.match_type === 'exact'
|
||||||
|
? lowerText === lowerKeyword
|
||||||
|
: watch.match_type === 'starts_with'
|
||||||
|
? lowerText.startsWith(lowerKeyword)
|
||||||
|
: lowerText.includes(lowerKeyword);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
matches.push({
|
||||||
|
keyword: watch.keyword,
|
||||||
|
watch_id: watch.id,
|
||||||
|
segment_text: text,
|
||||||
|
elapsed_seconds: elapsedSeconds,
|
||||||
|
matched_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeSession) {
|
||||||
|
try {
|
||||||
|
const { _accessToken: kwToken } = get();
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(kwToken ? { 'Authorization': `Bearer ${kwToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: activeSession.id,
|
||||||
|
keyword_watch_id: watch.id,
|
||||||
|
keyword_text: watch.keyword,
|
||||||
|
matched_in_text: text,
|
||||||
|
session_elapsed_seconds: elapsedSeconds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to log keyword event:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearKeywordMatches: () => {
|
||||||
|
set({ keywordMatches: [] });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* tailwind-compat.css
|
||||||
|
* Minimal CSS shim replacing the Tailwind build pipeline.
|
||||||
|
* Covers only the utility classes actually used in this codebase.
|
||||||
|
* Generated from grep audit of 8 affected files (B3 Tailwind removal sprint).
|
||||||
|
* Over time, replace these with --cc-* tokens or MUI sx props and remove entries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────── */
|
||||||
|
.flex { display: flex; }
|
||||||
|
.grid { display: grid; }
|
||||||
|
.inline-flex { display: inline-flex; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.block { display: block; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
.flex-1 { flex: 1 1 0%; }
|
||||||
|
.flex-shrink-0 { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.items-start { align-items: flex-start; }
|
||||||
|
.items-end { align-items: flex-end; }
|
||||||
|
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-end { justify-content: flex-end; }
|
||||||
|
.justify-start { justify-content: flex-start; }
|
||||||
|
|
||||||
|
/* ── Position ───────────────────────────────────────────────────────────── */
|
||||||
|
.relative { position: relative; }
|
||||||
|
.absolute { position: absolute; }
|
||||||
|
.fixed { position: fixed; }
|
||||||
|
.sticky { position: sticky; }
|
||||||
|
|
||||||
|
/* ── Spacing — gap ──────────────────────────────────────────────────────── */
|
||||||
|
.gap-1 { gap: 0.25rem; }
|
||||||
|
.gap-2 { gap: 0.5rem; }
|
||||||
|
.gap-3 { gap: 0.75rem; }
|
||||||
|
.gap-4 { gap: 1rem; }
|
||||||
|
.gap-6 { gap: 1.5rem; }
|
||||||
|
|
||||||
|
/* ── Spacing — padding ──────────────────────────────────────────────────── */
|
||||||
|
.p-0 { padding: 0; }
|
||||||
|
.p-2 { padding: 0.5rem; }
|
||||||
|
.p-4 { padding: 1rem; }
|
||||||
|
.p-5 { padding: 1.25rem; }
|
||||||
|
.p-6 { padding: 1.5rem; }
|
||||||
|
.p-12 { padding: 3rem; }
|
||||||
|
|
||||||
|
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||||
|
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||||
|
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||||
|
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||||
|
.px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
|
||||||
|
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||||
|
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||||
|
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||||
|
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
|
||||||
|
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
|
||||||
|
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||||
|
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||||
|
.py-12{ padding-top: 3rem; padding-bottom: 3rem; }
|
||||||
|
.py-16{ padding-top: 4rem; padding-bottom: 4rem; }
|
||||||
|
|
||||||
|
.pt-4 { padding-top: 1rem; }
|
||||||
|
.pl-10{ padding-left: 2.5rem; }
|
||||||
|
.pr-4 { padding-right: 1rem; }
|
||||||
|
|
||||||
|
/* ── Spacing — margin ───────────────────────────────────────────────────── */
|
||||||
|
.mt-0 { margin-top: 0; }
|
||||||
|
.mt-1 { margin-top: 0.25rem; }
|
||||||
|
.mt-2 { margin-top: 0.5rem; }
|
||||||
|
.mt-4 { margin-top: 1rem; }
|
||||||
|
.mt-8 { margin-top: 2rem; }
|
||||||
|
|
||||||
|
.mb-1 { margin-bottom: 0.25rem; }
|
||||||
|
.mb-2 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-3 { margin-bottom: 0.75rem; }
|
||||||
|
.mb-4 { margin-bottom: 1rem; }
|
||||||
|
.mb-6 { margin-bottom: 1.5rem; }
|
||||||
|
.mb-8 { margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
.ml-2 { margin-left: 0.5rem; }
|
||||||
|
.ml-4 { margin-left: 1rem; }
|
||||||
|
.mr-1 { margin-right: 0.25rem; }
|
||||||
|
.mr-2 { margin-right: 0.5rem; }
|
||||||
|
.my-8 { margin-top: 2rem; margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
/* ── Space-between children ─────────────────────────────────────────────── */
|
||||||
|
.space-x-2 > * + * { margin-left: 0.5rem; }
|
||||||
|
.space-y-3 > * + * { margin-top: 0.75rem; }
|
||||||
|
.space-y-4 > * + * { margin-top: 1rem; }
|
||||||
|
.space-y-8 > * + * { margin-top: 2rem; }
|
||||||
|
|
||||||
|
/* ── Sizing ─────────────────────────────────────────────────────────────── */
|
||||||
|
.w-0 { width: 0; }
|
||||||
|
.w-4 { width: 1rem; }
|
||||||
|
.w-8 { width: 2rem; }
|
||||||
|
.w-10 { width: 2.5rem; }
|
||||||
|
.w-12 { width: 3rem; }
|
||||||
|
.w-16 { width: 4rem; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
|
||||||
|
.h-4 { height: 1rem; }
|
||||||
|
.h-8 { height: 2rem; }
|
||||||
|
.h-10 { height: 2.5rem; }
|
||||||
|
.h-12 { height: 3rem; }
|
||||||
|
.h-64 { height: 16rem; }
|
||||||
|
.h-full { height: 100%; }
|
||||||
|
|
||||||
|
.min-h-screen { min-height: 100vh; }
|
||||||
|
.max-w-md { max-width: 28rem; }
|
||||||
|
.max-w-4xl { max-width: 56rem; }
|
||||||
|
.max-w-6xl { max-width: 72rem; }
|
||||||
|
.max-w-7xl { max-width: 80rem; }
|
||||||
|
|
||||||
|
/* ── Typography ─────────────────────────────────────────────────────────── */
|
||||||
|
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||||
|
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||||
|
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||||
|
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||||
|
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
|
||||||
|
|
||||||
|
.font-medium { font-weight: 500; }
|
||||||
|
.font-semibold { font-weight: 600; }
|
||||||
|
.font-bold { font-weight: 700; }
|
||||||
|
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
.uppercase { text-transform: uppercase; }
|
||||||
|
.capitalize { text-transform: capitalize; }
|
||||||
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Colours — text ─────────────────────────────────────────────────────── */
|
||||||
|
.text-white { color: #ffffff; }
|
||||||
|
.text-gray-300 { color: #d1d5db; }
|
||||||
|
.text-gray-400 { color: #9ca3af; }
|
||||||
|
.text-gray-500 { color: #6b7280; }
|
||||||
|
.text-gray-600 { color: #4b5563; }
|
||||||
|
.text-gray-700 { color: #374151; }
|
||||||
|
.text-gray-900 { color: #111827; }
|
||||||
|
.text-blue-600 { color: #2563eb; }
|
||||||
|
.text-blue-700 { color: #1d4ed8; }
|
||||||
|
.text-red-600 { color: #dc2626; }
|
||||||
|
.text-red-700 { color: #b91c1c; }
|
||||||
|
.text-red-800 { color: #991b1b; }
|
||||||
|
.text-green-600 { color: #16a34a; }
|
||||||
|
.text-green-700 { color: #15803d; }
|
||||||
|
.text-purple-600 { color: #9333ea; }
|
||||||
|
.text-purple-700 { color: #7e22ce; }
|
||||||
|
.text-yellow-700 { color: #a16207; }
|
||||||
|
|
||||||
|
/* ── Colours — background ───────────────────────────────────────────────── */
|
||||||
|
.bg-white { background-color: #ffffff; }
|
||||||
|
.bg-transparent { background-color: transparent; }
|
||||||
|
.bg-gray-50 { background-color: #f9fafb; }
|
||||||
|
.bg-gray-100 { background-color: #f3f4f6; }
|
||||||
|
.bg-gray-500 { background-color: #6b7280; }
|
||||||
|
.bg-blue-50 { background-color: #eff6ff; }
|
||||||
|
.bg-blue-100 { background-color: #dbeafe; }
|
||||||
|
.bg-blue-600 { background-color: #2563eb; }
|
||||||
|
.bg-red-50 { background-color: #fef2f2; }
|
||||||
|
.bg-red-100 { background-color: #fee2e2; }
|
||||||
|
.bg-red-600 { background-color: #dc2626; }
|
||||||
|
.bg-green-100 { background-color: #dcfce7; }
|
||||||
|
.bg-purple-100 { background-color: #f3e8ff; }
|
||||||
|
.bg-yellow-100 { background-color: #fef9c3; }
|
||||||
|
|
||||||
|
/* ── Borders ────────────────────────────────────────────────────────────── */
|
||||||
|
.border { border-width: 1px; border-style: solid; }
|
||||||
|
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||||
|
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
|
||||||
|
.border-2 { border-width: 2px; border-style: solid; }
|
||||||
|
.border-transparent { border-color: transparent; }
|
||||||
|
.border-gray-100 { border-color: #f3f4f6; }
|
||||||
|
.border-gray-200 { border-color: #e5e7eb; }
|
||||||
|
.border-gray-300 { border-color: #d1d5db; }
|
||||||
|
.border-blue-500 { border-color: #3b82f6; }
|
||||||
|
.border-blue-600 { border-color: #2563eb; }
|
||||||
|
.border-red-200 { border-color: #fecaca; }
|
||||||
|
|
||||||
|
/* ── Border radius ──────────────────────────────────────────────────────── */
|
||||||
|
.rounded { border-radius: 0.25rem; }
|
||||||
|
.rounded-md { border-radius: 0.375rem; }
|
||||||
|
.rounded-lg { border-radius: 0.5rem; }
|
||||||
|
.rounded-xl { border-radius: 0.75rem; }
|
||||||
|
.rounded-full { border-radius: 9999px; }
|
||||||
|
|
||||||
|
/* ── Shadows ────────────────────────────────────────────────────────────── */
|
||||||
|
.shadow { box-shadow: 0 1px 3px 0 rgb(0 0 0/.1),0 1px 2px -1px rgb(0 0 0/.1); }
|
||||||
|
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0/.05); }
|
||||||
|
.shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0/.1),0 2px 4px -2px rgb(0 0 0/.1); }
|
||||||
|
.shadow-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0/.1),0 4px 6px -4px rgb(0 0 0/.1); }
|
||||||
|
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0/.1),0 8px 10px -6px rgb(0 0 0/.1); }
|
||||||
|
|
||||||
|
/* ── Overflow / z-index ─────────────────────────────────────────────────── */
|
||||||
|
.overflow-hidden { overflow: hidden; }
|
||||||
|
.overflow-y-auto { overflow-y: auto; }
|
||||||
|
.z-50 { z-index: 50; }
|
||||||
|
|
||||||
|
/* ── Misc ───────────────────────────────────────────────────────────────── */
|
||||||
|
.opacity-50 { opacity: 0.5; }
|
||||||
|
.opacity-75 { opacity: 0.75; }
|
||||||
|
.cursor-not-allowed { cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Pseudo-classes ─────────────────────────────────────────────────────── */
|
||||||
|
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
|
||||||
|
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
|
||||||
|
.hover\:bg-blue-200:hover { background-color: #bfdbfe; }
|
||||||
|
.hover\:bg-blue-700:hover { background-color: #1d4ed8; }
|
||||||
|
.hover\:bg-green-200:hover { background-color: #bbf7d0; }
|
||||||
|
.hover\:bg-red-200:hover { background-color: #fecaca; }
|
||||||
|
.hover\:bg-red-700:hover { background-color: #b91c1c; }
|
||||||
|
.hover\:text-gray-500:hover { color: #6b7280; }
|
||||||
|
.hover\:text-gray-700:hover { color: #374151; }
|
||||||
|
.hover\:text-gray-800:hover { color: #1f2937; }
|
||||||
|
.hover\:text-blue-600:hover { color: #2563eb; }
|
||||||
|
.hover\:text-red-800:hover { color: #991b1b; }
|
||||||
|
.hover\:border-blue-300:hover { border-color: #93c5fd; }
|
||||||
|
|
||||||
|
.focus\:outline-none:focus { outline: none; }
|
||||||
|
.focus\:ring-2:focus { box-shadow: 0 0 0 2px #3b82f6; }
|
||||||
|
|
||||||
|
.disabled\:opacity-50:disabled { opacity: 0.5; }
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Exam-marker types — mirror the FastAPI /api/exam contract (see api routers/exam/schemas.py).
|
||||||
|
* Supabase is source of truth for this operational data; the graph (cc.public.exams) joins by
|
||||||
|
* the shared UUIDs (template/question/region ids, exam_code).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExamTemplateStatus = 'draft' | 'ready' | 'archived';
|
||||||
|
|
||||||
|
export interface ExamTemplate {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string | null;
|
||||||
|
exam_id: string | null;
|
||||||
|
exam_code: string | null;
|
||||||
|
source_file_id: string | null;
|
||||||
|
page_count: number | null;
|
||||||
|
institute_id: string;
|
||||||
|
teacher_id: string;
|
||||||
|
status: ExamTemplateStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplatePayload {
|
||||||
|
title: string;
|
||||||
|
subject?: string;
|
||||||
|
exam_id?: string;
|
||||||
|
exam_code?: string;
|
||||||
|
source_file_id?: string;
|
||||||
|
page_count?: number;
|
||||||
|
institute_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
|
||||||
|
|
||||||
|
export interface MarkSchemePoint {
|
||||||
|
mark: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemeLevel {
|
||||||
|
level: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
descriptor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemePart {
|
||||||
|
label: string;
|
||||||
|
marks: number;
|
||||||
|
guidance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemeChecklistItem {
|
||||||
|
text: string;
|
||||||
|
marks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkScheme {
|
||||||
|
type?: MarkSchemeType;
|
||||||
|
points?: MarkSchemePoint[];
|
||||||
|
levels?: MarkSchemeLevel[];
|
||||||
|
parts?: MarkSchemePart[];
|
||||||
|
checklist?: MarkSchemeChecklistItem[];
|
||||||
|
text?: string;
|
||||||
|
notes?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTemplateMetaPayload {
|
||||||
|
title?: string;
|
||||||
|
subject?: string | null;
|
||||||
|
page_count?: number | null;
|
||||||
|
status?: ExamTemplateStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
|
export interface ExamQuestion {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
max_marks: number;
|
||||||
|
answer_type: string | null;
|
||||||
|
mcq_options: unknown | null;
|
||||||
|
mark_scheme: MarkScheme;
|
||||||
|
is_container: boolean;
|
||||||
|
spec_ref: string | null;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
page?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExamResponseAreaKind =
|
||||||
|
| 'response'
|
||||||
|
| 'context'
|
||||||
|
| 'question_number'
|
||||||
|
| 'mark_area'
|
||||||
|
| 'reference'
|
||||||
|
| 'furniture';
|
||||||
|
|
||||||
|
export interface ExamResponseArea {
|
||||||
|
id: string;
|
||||||
|
question_id: string;
|
||||||
|
template_id: string;
|
||||||
|
page: number;
|
||||||
|
bounds: Record<string, number>;
|
||||||
|
kind: ExamResponseAreaKind;
|
||||||
|
response_form: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
|
source: 'manual' | 'ai';
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamBoundary {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
question_id: string | null;
|
||||||
|
label: string | null;
|
||||||
|
page_index: number;
|
||||||
|
y: number;
|
||||||
|
bounds: Record<string, number> | null;
|
||||||
|
source: 'manual' | 'ai';
|
||||||
|
confirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamTemplateDetail extends ExamTemplate {
|
||||||
|
questions: ExamQuestion[];
|
||||||
|
response_areas: ExamResponseArea[];
|
||||||
|
boundaries: ExamBoundary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TemplateReplacePayload {
|
||||||
|
meta?: {
|
||||||
|
title?: string;
|
||||||
|
subject?: string;
|
||||||
|
page_count?: number;
|
||||||
|
status?: ExamTemplateStatus;
|
||||||
|
};
|
||||||
|
questions: Array<{
|
||||||
|
id?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
order?: number;
|
||||||
|
max_marks?: number;
|
||||||
|
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||||
|
mcq_options?: unknown | null;
|
||||||
|
mark_scheme?: Record<string, unknown>;
|
||||||
|
is_container?: boolean;
|
||||||
|
spec_ref?: string | null;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
page?: number | null;
|
||||||
|
}>;
|
||||||
|
response_areas: Array<{
|
||||||
|
id?: string;
|
||||||
|
question_id: string;
|
||||||
|
page: number;
|
||||||
|
bounds: Record<string, number>;
|
||||||
|
kind: ExamResponseArea['kind'];
|
||||||
|
response_form?: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
|
source?: 'manual' | 'ai';
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
}>;
|
||||||
|
boundaries: Array<{
|
||||||
|
id?: string;
|
||||||
|
question_id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
page_index: number;
|
||||||
|
y: number;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
source?: 'manual' | 'ai';
|
||||||
|
confirmed?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchQuestionPayload {
|
||||||
|
label?: string;
|
||||||
|
order?: number;
|
||||||
|
max_marks?: number;
|
||||||
|
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||||
|
mcq_options?: unknown;
|
||||||
|
mark_scheme?: MarkScheme;
|
||||||
|
is_container?: boolean;
|
||||||
|
spec_ref?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecPoint {
|
||||||
|
uid?: string;
|
||||||
|
uuid_string?: string;
|
||||||
|
ref: string;
|
||||||
|
description: string;
|
||||||
|
spec_code: string;
|
||||||
|
exam_board_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Neo4jSyncResult {
|
||||||
|
status: string;
|
||||||
|
projection?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkingBatch {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
class_id: string | null;
|
||||||
|
institute_id: string;
|
||||||
|
teacher_id: string;
|
||||||
|
title: string | null;
|
||||||
|
status: 'open' | 'closed' | 'archived' | string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
submission_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentSubmission {
|
||||||
|
id: string;
|
||||||
|
batch_id: string;
|
||||||
|
student_id: string | null;
|
||||||
|
student_name: string | null;
|
||||||
|
status: 'absent' | 'unmatched' | 'matched' | 'marking' | 'complete' | string;
|
||||||
|
storage_path?: string | null;
|
||||||
|
mark_entry_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchQueueResponse {
|
||||||
|
batch: MarkingBatch;
|
||||||
|
submissions: StudentSubmission[];
|
||||||
|
progress: {
|
||||||
|
total: number;
|
||||||
|
absent: number;
|
||||||
|
complete: number;
|
||||||
|
in_progress: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamResultRow {
|
||||||
|
submission_id: string;
|
||||||
|
student_id: string | null;
|
||||||
|
student_name: string | null;
|
||||||
|
status: string | null;
|
||||||
|
marks: Record<string, number | null | undefined>;
|
||||||
|
total: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchResultsResponse {
|
||||||
|
batch: MarkingBatch;
|
||||||
|
questions: Array<Pick<ExamQuestion, 'id' | 'label' | 'max_marks' | 'order'>>;
|
||||||
|
results: ExamResultRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBatchPayload {
|
||||||
|
template_id: string;
|
||||||
|
class_id?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkUpsertPayload {
|
||||||
|
submission_id: string;
|
||||||
|
question_id: string;
|
||||||
|
awarded_marks: number;
|
||||||
|
mark_scheme_detail?: Record<string, unknown>;
|
||||||
|
annotation_shape_ids?: unknown;
|
||||||
|
comment?: string;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}
|
||||||
@@ -227,6 +227,13 @@ export interface UnifiedContextSwitch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Navigation Actions Interface
|
// Navigation Actions Interface
|
||||||
|
export interface NeoGraphNode {
|
||||||
|
neo4j_node_id: string;
|
||||||
|
neo4j_db_name: string;
|
||||||
|
node_type: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NavigationActions {
|
export interface NavigationActions {
|
||||||
// Unified Context Switch
|
// Unified Context Switch
|
||||||
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||||
@@ -239,6 +246,7 @@ export interface NavigationActions {
|
|||||||
// Node Navigation
|
// Node Navigation
|
||||||
navigate: (nodeId: string, dbName: string) => Promise<void>;
|
navigate: (nodeId: string, dbName: string) => Promise<void>;
|
||||||
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
|
||||||
|
navigateToNeoNode: (neoNode: NeoGraphNode) => Promise<void>;
|
||||||
|
|
||||||
// History Navigation
|
// History Navigation
|
||||||
goBack: () => void;
|
goBack: () => void;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user