feat(phase-b): student enrollment UI, teacher/student management pages, lesson views

- ClassDetailPage: full rewrite with MUI tabs (students, enrollment requests, teachers),
  add/remove students, approve/reject enrollment requests, AddStudentDialog
- StudentLessonsPage: new — student's weekly lesson view with week navigation
- TaughtLessonsPage: teacher's taught lesson week view
- SchoolSettingsPage, StaffManagerPage, StudentManagerPage: school admin management pages
- PlatformAdminPage: platform admin reset/seed controls
- Header: expanded nav menu (student lessons, school management, platform admin items)
- AppRoutes: routes for all new pages
- SchoolCalendarWizard, TeacherTimetableWizard: week_cycle support and improvements
- CCGraphNavPanel: updated navigation integration
- index.ts: export all new timetable pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-27 02:56:04 +01:00
parent 138dfb1531
commit 6ac5ab7b5c
13 changed files with 2663 additions and 384 deletions

View File

@ -12,6 +12,7 @@ import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
import AdminDashboard from './pages/auth/adminPage';
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
import DevPage from './pages/tldraw/devPage';
import TeacherPlanner from './pages/react-flow/teacherPlanner';
@ -31,8 +32,14 @@ import {
TimetablePage,
ClassesPage,
LessonPage,
TaughtLessonsPage,
MyClassesPage,
EnrollmentRequestsPage,
StaffManagerPage,
StudentManagerPage,
SchoolSettingsPage,
ClassDetailPage,
StudentLessonsPage,
} from './pages/timetable';
const FullContextRoutes: React.FC = () => {
@ -114,7 +121,7 @@ const AppRoutes: React.FC = () => {
<Route
path="/admin"
element={
user?.user_type === 'admin' ? <AdminDashboard /> : <NotFound />
<PlatformAdminPage />
}
/>
@ -125,8 +132,14 @@ const AppRoutes: React.FC = () => {
<Route path="/timetable" element={<TimetablePage />} />
<Route path="/classes" element={<ClassesPage />} />
<Route path="/my-classes" element={<MyClassesPage />} />
<Route path="/classes/:classId" element={<ClassDetailPage />} />
<Route path="/student-lessons" element={<StudentLessonsPage />} />
<Route path="/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 />} />

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import {
@ -19,9 +19,6 @@ import Login from '@mui/icons-material/Login';
import Logout from '@mui/icons-material/Logout';
import Teacher from '@mui/icons-material/School';
import Student from '@mui/icons-material/Person';
import TLDrawDev from '@mui/icons-material/Dashboard';
import DevTools from '@mui/icons-material/Build';
import Multiplayer from '@mui/icons-material/Groups';
import Calendar from '@mui/icons-material/CalendarToday';
import TeacherPlanner from '@mui/icons-material/Assignment';
import ExamMarker from '@mui/icons-material/AssignmentTurnedIn';
@ -33,32 +30,61 @@ import Schedule from '@mui/icons-material/Schedule';
import Class from '@mui/icons-material/Class';
import Book from '@mui/icons-material/Book';
import Enrollment from '@mui/icons-material/HowToReg';
import Lessons from '@mui/icons-material/EventNote';
import People from '@mui/icons-material/People';
import SchoolSettings from '@mui/icons-material/Tune';
import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig';
import { GraphNavigator } from '../components/navigation/GraphNavigator';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const Header: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const { user, signOut } = useAuth();
const { user, signOut, accessToken } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [isPlatformAdmin, setIsPlatformAdmin] = useState(false);
const [schoolRole, setSchoolRole] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
const showGraphNavigation = location.pathname === '/single-player';
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
// Update authentication state whenever user changes
useEffect(() => {
const newAuthState = !!user;
setIsAuthenticated(newAuthState);
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]);
setIsAuthenticated(!!user);
}, [user]);
// Check platform admin status and school role once on login
const checkAdminStatus = useCallback(async () => {
if (!accessToken) return;
// Platform admin check
try {
const res = await fetch(`${API_BASE}/admin/stats`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
setIsPlatformAdmin(res.ok);
} catch {
setIsPlatformAdmin(false);
}
// School role check
try {
const res = await fetch(`${API_BASE}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.ok) {
const data = await res.json();
setSchoolRole(data.user_role || null);
}
} catch {
setSchoolRole(null);
}
}, [accessToken]);
useEffect(() => {
if (accessToken) checkAdminStatus();
else { setIsPlatformAdmin(false); setSchoolRole(null); }
}, [accessToken, checkAdminStatus]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@ -244,8 +270,57 @@ const Header: React.FC = () => {
secondary="Review pending enrollments"
/>
</MenuItem>,
<MenuItem key="my-lessons" onClick={() => handleNavigation('/my-lessons')}>
<ListItemIcon>
<Lessons />
</ListItemIcon>
<ListItemText
primary="My Lessons"
secondary="Weekly lesson view"
/>
</MenuItem>,
<MenuItem key="student-lessons" onClick={() => handleNavigation('/student-lessons')}>
<ListItemIcon>
<Student />
</ListItemIcon>
<ListItemText
primary="Student Lessons"
secondary="Your class timetable"
/>
</MenuItem>,
<Divider key="timetable-divider" />,
// School Admin Section
...(isSchoolAdmin ? [
<Typography
key="school-admin-header"
variant="subtitle2"
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 key="staff-manager" onClick={() => handleNavigation('/staff-manager')}>
<ListItemIcon><People /></ListItemIcon>
<ListItemText primary="Staff Manager" secondary="Invite & manage teachers" />
</MenuItem>,
<MenuItem key="student-manager" onClick={() => handleNavigation('/student-manager')}>
<ListItemIcon><Teacher /></ListItemIcon>
<ListItemText primary="Student Manager" secondary="Invite & manage students" />
</MenuItem>,
<Divider key="school-admin-divider" />,
] : []),
// Features Section
<Typography
key="features-header"
@ -311,8 +386,8 @@ const Header: React.FC = () => {
<ListItemText primary="Search" />
</MenuItem>,
// Admin Section
...(isAdmin ? [
// Platform Admin Section
...(isPlatformAdmin ? [
<Divider key="admin-divider" />,
<Typography
key="admin-header"
@ -330,10 +405,8 @@ const Header: React.FC = () => {
Administration
</Typography>,
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
<ListItemIcon>
<Admin />
</ListItemIcon>
<ListItemText primary="Admin Dashboard" />
<ListItemIcon><Admin /></ListItemIcon>
<ListItemText primary="Platform Admin" secondary="Schools & system stats" />
</MenuItem>
] : []),

View File

@ -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_URL || 'http://localhost:8000';
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;

View File

@ -1,324 +1,433 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { AccessTime, Add, ArrowBack, CalendarToday, Delete, Edit, MenuBook, People } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore';
import { useUser } from '../../contexts/UserContext';
import Modal from '../../components/common/Modal';
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, CircularProgress, Alert, Chip, Divider,
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
Autocomplete, IconButton, Tooltip, Tabs, Tab, Avatar,
} from '@mui/material';
import {
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// ─── 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 { profile } = useUser();
const {
currentClass,
timetables,
enrolledStudents,
classTeachers,
classDetailLoading,
classDetailError,
fetchClassDetail,
deleteClass,
clearCurrentClass,
} = useTimetableStore();
const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate();
const { accessToken, user } = useAuth();
const [activeTab, setActiveTab] = useState<'timetables' | 'students' | 'teachers'>('timetables');
const [showDeleteModal, setShowDeleteModal] = useState(false);
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);
useEffect(() => {
if (classId) {
fetchClassDetail(classId);
}
return () => {
clearCurrentClass();
const load = useCallback(async () => {
if (!accessToken || !classId) return;
setLoading(true);
setError(null);
try {
const [clsRes, roleRes] = await Promise.all([
fetch(`${API_BASE}/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json()),
fetch(`${API_BASE}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json()),
]);
if (clsRes.id) setCls(clsRes);
else setError(clsRes.detail || 'Class not found');
const role = roleRes.user_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken, classId]);
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();
};
}, [classId, fetchClassDetail, clearCurrentClass]);
const handleDeleteClass = async () => {
if (!classId) return;
await deleteClass(classId);
navigate('/timetable/classes');
};
const apiDelete = async (path: string) => {
await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
};
const isOwner = currentClass?.created_by === profile?.id;
const isTeacher = classTeachers.some(t => t.teacher_id === profile?.id && t.is_primary);
const apiPatch = async (path: string, body: object) => {
const r = await fetch(`${API_BASE}${path}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
};
const handleAddStudent = async (studentId: string) => {
setActionError(null);
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Failed to add student');
};
const handleRemoveStudent = async (studentId: string) => {
setActionError(null);
await apiDelete(`/classes/${classId}/students/${studentId}`);
load();
};
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
setActionError(null);
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Action failed');
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
if (error || !cls) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{error || 'Class not found'}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/classes')}>
Back to Classes
</Button>
</Box>
);
}
const existingStudentIds = new Set(cls.students.map(s => s.student_id));
const pendingCount = cls.enrollment_requests.length;
if (classDetailLoading) {
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 (classDetailError || !currentClass) {
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 Class</h2>
<p className="text-red-600">{classDetailError || 'Class 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"
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowBack size={18} />
Back to Classes
</Link>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-gray-900">{currentClass.name}</h1>
<span className="px-3 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded-full">
{currentClass.subject}
</span>
</div>
<p className="text-gray-500">
{currentClass.school_year} {currentClass.academic_term}
</p>
</div>
{(isOwner || isTeacher) && (
<div className="flex items-center gap-2">
<Link
to={`/timetable/classes/${classId}/edit`}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
<Edit size={18} />
Edit
</Link>
<button
onClick={() => setShowDeleteModal(true)}
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 size={18} />
Delete
</button>
</div>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<CalendarToday className="text-blue-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Timetables</p>
<p className="text-xl font-semibold text-gray-900">{currentClass.timetable_count}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-50 rounded-lg">
<People className="text-green-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Students</p>
<p className="text-xl font-semibold text-gray-900">{currentClass.student_count}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 rounded-lg">
<MenuBook className="text-purple-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Teachers</p>
<p className="text-xl font-semibold text-gray-900">{classTeachers.length}</p>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="flex gap-6">
<button
onClick={() => setActiveTab('timetables')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'timetables'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Timetables
</button>
<button
onClick={() => setActiveTab('students')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'students'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Students ({enrolledStudents.length})
</button>
<button
onClick={() => setActiveTab('teachers')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'teachers'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Teachers ({classTeachers.length})
</button>
</div>
</div>
{/* Tab Content */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
{activeTab === 'timetables' && (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Timetables</h2>
{(isOwner || isTeacher) && (
<Link
to={`/timetable/classes/${classId}/timetables/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 size={18} />
Add Timetable
</Link>
)}
</div>
{timetables.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<AccessTime size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No timetables yet</p>
<p>Create a timetable to start scheduling lessons</p>
</div>
) : (
<div className="space-y-3">
{timetables.map((timetable) => (
<Link
key={timetable.id}
to={`/timetable/timetables/${timetable.id}`}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<div>
<h3 className="font-medium text-gray-900">{timetable.name}</h3>
<p className="text-sm text-gray-500">
{timetable.lesson_count} lessons
{timetable.is_recurring && ' • Recurring'}
</p>
</div>
<ArrowBack className="rotate-180 text-gray-400" size={18} />
</Link>
))}
</div>
)}
</div>
)}
{activeTab === 'students' && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Enrolled Students</h2>
{enrolledStudents.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<People size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No students enrolled</p>
<p>Students can request enrollment or be added by teachers</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{enrolledStudents.map((student) => (
<div
key={student.user_id}
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
>
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-600 font-medium">
{student.full_name.charAt(0)}
</span>
</div>
<div>
<p className="font-medium text-gray-900">{student.full_name}</p>
<p className="text-sm text-gray-500">{student.email}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'teachers' && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Teachers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{classTeachers.map((teacher) => (
<div
key={teacher.teacher_id}
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
>
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-medium">
{teacher.full_name.charAt(0)}
</span>
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">{teacher.full_name}</p>
<p className="text-sm text-gray-500">{teacher.email}</p>
</div>
{teacher.is_primary && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
Primary
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Delete Modal */}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Delete Class"
>
<div className="p-6">
<p className="text-gray-600 mb-6">
Are you sure you want to delete "{currentClass.name}"? This action cannot be undone and will remove all timetables, lessons, and whiteboards associated with this class.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteModal(false)}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
{/* Header */}
<Button
size="small"
startIcon={<ArrowBack />}
onClick={() => navigate('/classes')}
sx={{ mb: 2 }}
>
Cancel
</button>
<button
onClick={handleDeleteClass}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Delete Class
</button>
</div>
</div>
</Modal>
</div>
);
Back to Classes
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" fontWeight={700}>{cls.name}</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
{cls.class_code && <Chip label={cls.class_code} size="small" />}
{cls.subject && <Chip label={cls.subject} size="small" variant="outlined" />}
{cls.year_group && <Chip label={`Y${cls.year_group}`} size="small" variant="outlined" />}
<Chip
label={cls.is_active ? 'Active' : 'Inactive'}
size="small"
color={cls.is_active ? 'success' : 'default'}
/>
</Box>
{cls.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{cls.description}
</Typography>
)}
</Box>
<School sx={{ color: 'primary.main', fontSize: 40, opacity: 0.3 }} />
</Box>
{actionError && (
<Alert severity="error" onClose={() => setActionError(null)} sx={{ mb: 2 }}>
{actionError}
</Alert>
)}
{/* Tabs */}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tab label={`Students (${cls.student_count})`} />
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
<Tab label={`Teachers (${cls.teachers.length})`} />
</Tabs>
{/* Students tab */}
{tab === 0 && (
<Box>
{isAdmin && (
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="small"
startIcon={<PersonAdd />}
onClick={() => setAddOpen(true)}
>
Add Student
</Button>
</Box>
)}
{cls.students.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No students enrolled yet
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.students.map(s => (
<Box
key={s.student_id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'primary.light' }}>
{initials(s.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{s.profile?.full_name || s.student_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{s.profile?.email}
</Typography>
</Box>
{isAdmin && (
<Tooltip title="Remove student">
<IconButton
size="small"
color="error"
onClick={() => handleRemoveStudent(s.student_id)}
>
<PersonRemove fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{/* Enrollment requests tab */}
{tab === 1 && (
<Box>
{cls.enrollment_requests.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No pending enrollment requests
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.enrollment_requests.map(req => (
<Box
key={req.id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'warning.light',
borderRadius: 1, bgcolor: 'warning.50',
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'warning.light' }}>
{initials(req.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{req.profile?.full_name || req.student_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{req.profile?.email} · requested{' '}
{new Date(req.created_at).toLocaleDateString('en-GB')}
</Typography>
</Box>
{isAdmin && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Approve">
<IconButton
size="small"
color="success"
onClick={() => handleEnrollmentResponse(req.id, 'approve')}
>
<CheckCircle fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Reject">
<IconButton
size="small"
color="error"
onClick={() => handleEnrollmentResponse(req.id, 'reject')}
>
<Cancel fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{/* Teachers tab */}
{tab === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.teachers.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No teachers assigned
</Typography>
) : (
cls.teachers.map(t => (
<Box
key={t.teacher_id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'secondary.light' }}>
{initials(t.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{t.profile?.full_name || t.teacher_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{t.profile?.email}
</Typography>
</Box>
{t.is_primary && (
<Chip label="Primary" size="small" color="primary" />
)}
</Box>
))
)}
</Box>
)}
<AddStudentDialog
open={addOpen}
onClose={() => setAddOpen(false)}
onAdd={handleAddStudent}
accessToken={accessToken || ''}
existingIds={existingStudentIds}
/>
</Box>
);
};
export default ClassDetailPage;

View File

@ -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_URL || 'http://localhost:8000';
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;

View File

@ -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_URL || 'http://localhost:8000';
// ─── 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="teacher@school.edu"
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;

View File

@ -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_URL || 'http://localhost:8000';
// ─── 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;

View File

@ -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_URL || 'http://localhost:8000';
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="student@school.edu"
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;

View File

@ -0,0 +1,389 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
Dialog, DialogTitle, DialogContent, DialogActions,
TextField, Select, MenuItem, FormControl, InputLabel,
Divider, IconButton, Tooltip,
} from '@mui/material';
import {
ChevronLeft, ChevronRight, Today, EditNote,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// ─── 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;
}
function LessonCard({ lesson, onEdit }: 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>
<Tooltip title="Edit lesson">
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ ml: 0.5, p: 0.25 }}>
<EditNote fontSize="inherit" />
</IconButton>
</Tooltip>
</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 [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 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 totalLessons = days.reduce((sum, d) => sum + d.lessons.length, 0);
// Format week label
const weekDates = days.filter(d => d.lessons.length > 0 || true).map(d => d.date);
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}
/>
))
)}
</Box>
))}
</Box>
)}
<LessonEditDialog
lesson={editingLesson}
onClose={() => setEditingLesson(null)}
onSave={handleSaveLesson}
/>
</Box>
);
};
export default TaughtLessonsPage;

View File

@ -1,19 +1,17 @@
/**
* Timetable Module - Page Exports
*
* This barrel file exports all pages related to the timetable/classroom management system.
*
* @module pages/timetable
*/
// Main pages
export { default as TimetablePage } from './TimetablePage';
export { default as ClassesPage } from './ClassesPage';
export { default as LessonPage } from './LessonPage';
// Class management pages
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';
// Re-export types if needed
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';

View File

@ -170,8 +170,12 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
const handleClick = () => {
if (!isSection) {
onSelect(node);
} else if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) {
handleToggle({ stopPropagation: () => {} } as React.MouseEvent);
} else {
// Sections with a real node ID (e.g. the school section) navigate AND expand
if (node.neo4j_node_id) onSelect(node);
if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) {
handleToggle({ stopPropagation: () => {} } as React.MouseEvent);
}
}
};
@ -516,7 +520,7 @@ export function CCGraphNavPanel() {
}, [accessToken, apiBase]);
const handleSelect = useCallback((node: TreeNode) => {
if (!node.is_section) navigateToNeoNode(node);
if (!node.is_section || node.neo4j_node_id) navigateToNeoNode(node);
}, [navigateToNeoNode]);
const refreshAll = useCallback(() => {

View File

@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Stepper, Step, StepLabel, Box, TextField,
Typography, IconButton, Select, MenuItem, FormControl,
InputLabel, CircularProgress, Alert, Divider,
InputLabel, CircularProgress, Alert, Divider, Chip,
Tooltip,
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useAuth } from '../../../../../../contexts/AuthContext';
@ -13,6 +14,7 @@ interface TermInput {
term_number: number;
start_date: string;
end_date: string;
notes: string;
}
interface PeriodInput {
@ -20,7 +22,21 @@ interface PeriodInput {
name: string;
start_time: string;
end_time: string;
period_type: 'lesson' | 'break' | 'registration';
period_type: 'lesson' | 'break' | 'registration' | 'offtimetable';
}
interface TermBreakInput {
name: string;
start_date: string;
end_date: string;
}
interface WeekEntry {
termName: string;
termNumber: number;
weekNumber: number;
startDate: string;
cycle: 'A' | 'B';
}
export interface SchoolInfo {
@ -34,9 +50,14 @@ export interface SchoolInfo {
}
const DEFAULT_TERMS: TermInput[] = [
{ name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19' },
{ name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01' },
{ name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22' },
{ name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19', notes: '' },
{ name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01', notes: '' },
{ name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22', notes: '' },
];
const DEFAULT_TERM_BREAKS: TermBreakInput[] = [
{ name: 'Christmas Break', start_date: '2025-12-22', end_date: '2026-01-02' },
{ name: 'Easter Break', start_date: '2026-04-06', end_date: '2026-04-17' },
];
const DEFAULT_PERIODS: PeriodInput[] = [
@ -50,6 +71,49 @@ const DEFAULT_PERIODS: PeriodInput[] = [
{ code: 'P5', name: 'Period 5', start_time: '14:05', end_time: '15:05', period_type: 'lesson' },
];
// Mirror of Python _academic_weeks: yields {n, monday} for each Mon block overlapping term.
function getAcademicWeeksForTerm(
termStartStr: string,
termEndStr: string,
): { n: number; monday: string }[] {
if (!termStartStr || !termEndStr) return [];
const termStart = new Date(termStartStr + 'T00:00:00');
const termEnd = new Date(termEndStr + 'T00:00:00');
const dayOfWeek = termStart.getDay(); // 0=Sun
const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const current = new Date(termStart);
current.setDate(current.getDate() - daysFromMonday);
if (current < termStart) current.setDate(current.getDate() + 7);
const weeks: { n: number; monday: string }[] = [];
let n = 1;
while (current <= termEnd) {
weeks.push({ n, monday: current.toISOString().slice(0, 10) });
current.setDate(current.getDate() + 7);
n++;
}
return weeks;
}
function computeWeekEntries(terms: TermInput[]): WeekEntry[] {
const entries: WeekEntry[] = [];
for (const term of terms) {
if (!term.start_date || !term.end_date) continue;
const termWeeks = getAcademicWeeksForTerm(term.start_date, term.end_date);
for (const { n, monday } of termWeeks) {
// default A/B: odd week within term = A, even = B (mirrors backend)
const cycle: 'A' | 'B' = n % 2 === 1 ? 'A' : 'B';
entries.push({
termName: term.name,
termNumber: term.term_number,
weekNumber: n,
startDate: monday,
cycle,
});
}
}
return entries;
}
interface Props {
open: boolean;
onClose: () => void;
@ -64,21 +128,33 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Step 0
const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || '');
const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || '');
const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || '');
// Step 1
const [yearStart, setYearStart] = useState('2025-09-01');
const [yearEnd, setYearEnd] = useState('2026-07-31');
const [terms, setTerms] = useState<TermInput[]>(DEFAULT_TERMS);
// Step 2
const [termBreaks, setTermBreaks] = useState<TermBreakInput[]>(DEFAULT_TERM_BREAKS);
// Step 3 (periods)
const [periods, setPeriods] = useState<PeriodInput[]>(DEFAULT_PERIODS);
// Step 4 (week cycles) — computed from terms, user can override
const [weekEntries, setWeekEntries] = useState<WeekEntry[]>([]);
// ── Term helpers ─────────────────────────────────────────────────────────
const addTerm = () => setTerms(prev => [...prev, {
name: `Term ${prev.length + 1}`,
term_number: prev.length + 1,
start_date: '',
end_date: '',
notes: '',
}]);
const removeTerm = (i: number) => setTerms(prev =>
@ -88,6 +164,21 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
const updateTerm = (i: number, field: keyof TermInput, value: string) =>
setTerms(prev => prev.map((t, idx) => idx === i ? { ...t, [field]: value } : t));
// ── Term break helpers ───────────────────────────────────────────────────
const addTermBreak = () => setTermBreaks(prev => [...prev, {
name: `Break ${prev.length + 1}`,
start_date: '',
end_date: '',
}]);
const removeTermBreak = (i: number) => setTermBreaks(prev => prev.filter((_, idx) => idx !== i));
const updateTermBreak = (i: number, field: keyof TermBreakInput, value: string) =>
setTermBreaks(prev => prev.map((b, idx) => idx === i ? { ...b, [field]: value } : b));
// ── Period helpers ───────────────────────────────────────────────────────
const addPeriod = () => setPeriods(prev => [...prev, {
code: `P${prev.length + 1}`,
name: `Period ${prev.length + 1}`,
@ -101,6 +192,28 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
const updatePeriod = (i: number, field: keyof PeriodInput, value: string) =>
setPeriods(prev => prev.map((p, idx) => idx === i ? { ...p, [field]: value } : p));
// ── Week cycle helpers ───────────────────────────────────────────────────
const toggleWeekCycle = (idx: number) =>
setWeekEntries(prev => prev.map((w, i) =>
i === idx ? { ...w, cycle: w.cycle === 'A' ? 'B' : 'A' } : w
));
const resetWeekCycles = () =>
setWeekEntries(computeWeekEntries(terms));
// ── Navigation ───────────────────────────────────────────────────────────
const goToStep = (next: number) => {
// When entering step 4, compute week entries from current terms
if (next === 4) {
setWeekEntries(computeWeekEntries(terms));
}
setStep(next);
};
// ── Step 0: save school details ──────────────────────────────────────────
const handleSaveSchoolInfo = async () => {
if (!accessToken) return;
setSaving(true);
@ -124,23 +237,56 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
}
};
// ── Step 4: final save ───────────────────────────────────────────────────
const handleSaveCalendar = async () => {
if (!accessToken) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`${apiBase}/timetable/setup`, {
const payload = {
year_start: yearStart,
year_end: yearEnd,
terms: terms.map(t => ({
name: t.name,
term_number: t.term_number,
start_date: t.start_date,
end_date: t.end_date,
notes: t.notes || undefined,
})),
periods,
term_breaks: termBreaks.filter(b => b.name && b.start_date && b.end_date),
week_cycles: weekEntries.map(w => ({
term_number: w.termNumber,
week_number: w.weekNumber,
cycle: w.cycle,
})),
};
const setupRes = await fetch(`${apiBase}/timetable/setup`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ year_start: yearStart, year_end: yearEnd, terms, periods }),
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.status === 'ok') {
onComplete();
handleClose();
} else {
setError(data.message || 'Calendar setup failed');
const setupData = await setupRes.json();
if (setupData.status !== 'ok') {
setError(setupData.message || 'Calendar setup failed');
return;
}
// Materialize academic_periods rows
const matRes = await fetch(`${apiBase}/timetable/materialize-periods`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
});
const matData = await matRes.json();
if (matData.status !== 'ok') {
// Non-fatal: log and continue
console.warn('Materialize periods returned:', matData);
}
onComplete();
handleClose();
} catch (e: any) {
setError(e.message);
} finally {
@ -157,7 +303,18 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
const addr = schoolInfo.address || {};
const addressStr = [addr.street, addr.town, addr.county, addr.postcode].filter(Boolean).join(', ');
const STEPS = ['School Details', 'Academic Calendar', 'Daily Periods'];
const STEPS = ['School Details', 'Terms', 'Term Breaks', 'Daily Periods', 'Week Cycles'];
// Group week entries by term for display
const weeksByTerm = useMemo(() => {
const map: Record<string, WeekEntry[]> = {};
for (const w of weekEntries) {
const key = w.termName;
if (!map[key]) map[key] = [];
map[key].push(w);
}
return map;
}, [weekEntries]);
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
@ -171,6 +328,7 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
<DialogContent>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{/* ── Step 0: School Details ───────────────────────────────── */}
{step === 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Information</Typography>
@ -217,6 +375,7 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
</Box>
)}
{/* ── Step 1: Terms ────────────────────────────────────────── */}
{step === 1 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Year</Typography>
@ -234,17 +393,65 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
<Button size="small" startIcon={<AddIcon />} onClick={addTerm}>Add Term</Button>
</Box>
{terms.map((term, i) => (
<Box key={i} sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1.5, mb: 0.75, alignItems: 'center' }}>
<TextField label="Term Name" value={term.name}
onChange={e => updateTerm(i, 'name', e.target.value)}
size="small" sx={{ width: 140 }} />
<TextField label="Start Date" type="date" value={term.start_date}
onChange={e => updateTerm(i, 'start_date', e.target.value)}
size="small" InputLabelProps={{ shrink: true }} />
<TextField label="End Date" type="date" value={term.end_date}
onChange={e => updateTerm(i, 'end_date', e.target.value)}
size="small" InputLabelProps={{ shrink: true }} />
<IconButton size="small" onClick={() => removeTerm(i)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
<TextField
label="Notes (optional)"
value={term.notes}
onChange={e => updateTerm(i, 'notes', e.target.value)}
size="small"
fullWidth
multiline
minRows={1}
placeholder="Any notes about this term"
sx={{ pl: 0 }}
/>
</Box>
))}
</Box>
)}
{/* ── Step 2: Term Breaks ──────────────────────────────────── */}
{step === 2 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="subtitle2">Term Breaks</Typography>
<Button size="small" startIcon={<AddIcon />} onClick={addTermBreak}>Add Break</Button>
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 2 }}>
Named holiday periods between terms (Christmas, Easter, half-terms etc.)
</Typography>
{termBreaks.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
No term breaks defined. Click "Add Break" to add one, or skip this step.
</Typography>
)}
{termBreaks.map((tb, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
<TextField label="Term Name" value={term.name}
onChange={e => updateTerm(i, 'name', e.target.value)}
size="small" sx={{ width: 140 }} />
<TextField label="Start Date" type="date" value={term.start_date}
onChange={e => updateTerm(i, 'start_date', e.target.value)}
<TextField label="Break Name" value={tb.name}
onChange={e => updateTermBreak(i, 'name', e.target.value)}
size="small" sx={{ width: 180 }}
placeholder="e.g. Christmas Break" />
<TextField label="Start Date" type="date" value={tb.start_date}
onChange={e => updateTermBreak(i, 'start_date', e.target.value)}
size="small" InputLabelProps={{ shrink: true }} />
<TextField label="End Date" type="date" value={term.end_date}
onChange={e => updateTerm(i, 'end_date', e.target.value)}
<TextField label="End Date" type="date" value={tb.end_date}
onChange={e => updateTermBreak(i, 'end_date', e.target.value)}
size="small" InputLabelProps={{ shrink: true }} />
<IconButton size="small" onClick={() => removeTerm(i)}>
<IconButton size="small" onClick={() => removeTermBreak(i)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
@ -252,7 +459,8 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
</Box>
)}
{step === 2 && (
{/* ── Step 3: Daily Periods ────────────────────────────────── */}
{step === 3 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography variant="subtitle2">Daily Period Schedule</Typography>
@ -272,13 +480,14 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
<TextField label="End" type="time" value={p.end_time}
onChange={e => updatePeriod(i, 'end_time', e.target.value)}
size="small" InputLabelProps={{ shrink: true }} />
<FormControl size="small" sx={{ width: 130 }}>
<FormControl size="small" sx={{ width: 150 }}>
<InputLabel>Type</InputLabel>
<Select label="Type" value={p.period_type}
onChange={e => updatePeriod(i, 'period_type', e.target.value)}>
<MenuItem value="lesson">Lesson</MenuItem>
<MenuItem value="break">Break</MenuItem>
<MenuItem value="registration">Registration</MenuItem>
<MenuItem value="offtimetable">Off-timetable</MenuItem>
</Select>
</FormControl>
<IconButton size="small" onClick={() => removePeriod(i)}>
@ -288,6 +497,55 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
))}
</Box>
)}
{/* ── Step 4: Week Cycles ──────────────────────────────────── */}
{step === 4 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="subtitle2">Week A/B Cycles</Typography>
<Tooltip title="Reset to alternating A/B (default)">
<Button size="small" onClick={resetWeekCycles}>Reset Defaults</Button>
</Tooltip>
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 2 }}>
Default: Week 1 = A, Week 2 = B, alternating within each term. Click a chip to toggle.
</Typography>
{weekEntries.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
No weeks found check your term dates.
</Typography>
)}
{Object.entries(weeksByTerm).map(([termName, weeks]) => (
<Box key={termName} sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mb: 0.75, display: 'block' }}>
{termName}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{weeks.map((w) => {
const globalIdx = weekEntries.findIndex(
e => e.termNumber === w.termNumber && e.weekNumber === w.weekNumber
);
return (
<Tooltip
key={`${w.termNumber}-${w.weekNumber}`}
title={`w/c ${w.startDate}`}
>
<Chip
label={`W${w.weekNumber} ${w.cycle}`}
size="small"
color={w.cycle === 'A' ? 'primary' : 'secondary'}
variant="outlined"
onClick={() => toggleWeekCycle(globalIdx)}
sx={{ cursor: 'pointer', fontWeight: 600, minWidth: 56 }}
/>
</Tooltip>
);
})}
</Box>
</Box>
))}
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
@ -302,10 +560,20 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
)}
{step === 1 && (
<Button onClick={() => setStep(2)} variant="outlined" disabled={saving}>
Next: Daily Periods
Next: Term Breaks
</Button>
)}
{step === 2 && (
<Button onClick={() => setStep(3)} variant="outlined" disabled={saving}>
Next: Daily Periods
</Button>
)}
{step === 3 && (
<Button onClick={() => goToStep(4)} variant="outlined" disabled={saving}>
Next: Week Cycles
</Button>
)}
{step === 4 && (
<Button onClick={handleSaveCalendar} variant="contained" disabled={saving}>
{saving ? <CircularProgress size={18} /> : 'Save School Calendar'}
</Button>

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Box, TextField, Typography, Table, TableHead,
Button, Box, Typography, Table, TableHead,
TableBody, TableRow, TableCell, CircularProgress, Alert,
Autocomplete, TextField,
} from '@mui/material';
import { useAuth } from '../../../../../../contexts/AuthContext';
@ -14,6 +15,14 @@ export interface PeriodTemplate {
period_type: string;
}
interface ClassOption {
id: string;
name: string;
class_code?: string;
subject?: string;
year_group?: string;
}
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
function emptyGrid(): Record<string, Record<string, string>> {
@ -22,6 +31,13 @@ function emptyGrid(): Record<string, Record<string, string>> {
return g;
}
function classLabel(c: ClassOption): string {
const parts = [c.class_code || c.name];
if (c.year_group) parts.push(c.year_group);
if (c.subject && c.subject !== c.name) parts.push(c.subject);
return parts.join(' · ');
}
interface Props {
open: boolean;
onClose: () => void;
@ -43,9 +59,11 @@ export function TeacherTimetableWizard({
const [localTimetableId, setLocalTimetableId] = useState<string | null>(initialTimetableId);
const [initializing, setInitializing] = useState(false);
const [loadingSlots, setLoadingSlots] = useState(false);
const [loadingClasses, setLoadingClasses] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [grid, setGrid] = useState<Record<string, Record<string, string>>>(emptyGrid);
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
const slotsLoadedRef = useRef(false);
const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson');
@ -63,6 +81,23 @@ export function TeacherTimetableWizard({
slotsLoadedRef.current = false;
}, [open, initialTimetableId]);
// Load available classes for this institute
useEffect(() => {
if (!open || !accessToken || classOptions.length > 0) return;
setLoadingClasses(true);
fetch(`${apiBase}/database/timetable/classes?active_only=true&limit=200`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then(r => r.json())
.then(data => {
if (Array.isArray(data.classes)) {
setClassOptions(data.classes);
}
})
.catch(() => {})
.finally(() => setLoadingClasses(false));
}, [open, accessToken, apiBase, classOptions.length]);
// Auto-create TeacherTimetable node if not yet done
useEffect(() => {
if (!open || localTimetableId || !accessToken || initializing) return;
@ -153,6 +188,7 @@ export function TeacherTimetableWizard({
const handleClose = () => {
setError(null);
setClassOptions([]);
onClose();
};
@ -178,16 +214,21 @@ export function TeacherTimetableWizard({
{!initializing && !loadingSlots && localTimetableId && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
Enter your class codes for each lesson slot (leave blank if free)
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Select or type a class name for each lesson slot (leave blank if free)
</Typography>
{classOptions.length > 0 && (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 1.5 }}>
{classOptions.length} class{classOptions.length !== 1 ? 'es' : ''} available type to filter or enter a custom name
</Typography>
)}
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600, minWidth: 100 }}>Period</TableCell>
{DAYS.map(d => (
<TableCell key={d} align="center" sx={{ fontWeight: 600, minWidth: 110 }}>
<TableCell key={d} align="center" sx={{ fontWeight: 600, minWidth: 130 }}>
{d}
</TableCell>
))}
@ -208,15 +249,42 @@ export function TeacherTimetableWizard({
</TableCell>
{DAYS.map(day => (
<TableCell key={day} align="center" sx={{ p: 0.5 }}>
<TextField
<Autocomplete
freeSolo
size="small"
placeholder="—"
options={classOptions}
getOptionLabel={opt =>
typeof opt === 'string' ? opt : classLabel(opt)
}
value={grid[day]?.[period.code] || ''}
onChange={e => setCell(day, period.code, e.target.value)}
inputProps={{
style: { textAlign: 'center', fontSize: '0.8rem', padding: '4px 6px' },
onChange={(_, val) => {
const text =
val === null ? ''
: typeof val === 'string' ? val
: classLabel(val);
setCell(day, period.code, text);
}}
sx={{ width: 96 }}
onInputChange={(_, val) => setCell(day, period.code, val)}
renderInput={params => (
<TextField
{...params}
placeholder="—"
inputProps={{
...params.inputProps,
style: {
textAlign: 'center',
fontSize: '0.78rem',
padding: '3px 6px',
},
}}
sx={{ width: 120 }}
/>
)}
sx={{ width: 120 }}
disableClearable={false}
loading={loadingClasses}
noOptionsText="Type a class name"
ListboxProps={{ style: { maxHeight: 200 } }}
/>
</TableCell>
))}