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:
parent
138dfb1531
commit
6ac5ab7b5c
@ -12,6 +12,7 @@ import CalendarPage from './pages/user/calendarPage';
|
|||||||
import SettingsPage from './pages/user/settingsPage';
|
import SettingsPage from './pages/user/settingsPage';
|
||||||
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
||||||
import AdminDashboard from './pages/auth/adminPage';
|
import AdminDashboard from './pages/auth/adminPage';
|
||||||
|
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
||||||
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
||||||
import DevPage from './pages/tldraw/devPage';
|
import DevPage from './pages/tldraw/devPage';
|
||||||
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
||||||
@ -31,8 +32,14 @@ import {
|
|||||||
TimetablePage,
|
TimetablePage,
|
||||||
ClassesPage,
|
ClassesPage,
|
||||||
LessonPage,
|
LessonPage,
|
||||||
|
TaughtLessonsPage,
|
||||||
MyClassesPage,
|
MyClassesPage,
|
||||||
EnrollmentRequestsPage,
|
EnrollmentRequestsPage,
|
||||||
|
StaffManagerPage,
|
||||||
|
StudentManagerPage,
|
||||||
|
SchoolSettingsPage,
|
||||||
|
ClassDetailPage,
|
||||||
|
StudentLessonsPage,
|
||||||
} from './pages/timetable';
|
} from './pages/timetable';
|
||||||
|
|
||||||
const FullContextRoutes: React.FC = () => {
|
const FullContextRoutes: React.FC = () => {
|
||||||
@ -114,7 +121,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
user?.user_type === 'admin' ? <AdminDashboard /> : <NotFound />
|
<PlatformAdminPage />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -125,8 +132,14 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/timetable" element={<TimetablePage />} />
|
<Route path="/timetable" element={<TimetablePage />} />
|
||||||
<Route path="/classes" element={<ClassesPage />} />
|
<Route path="/classes" element={<ClassesPage />} />
|
||||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
<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="/enrollment-requests" element={<EnrollmentRequestsPage />} />
|
||||||
<Route path="/lessons/:lessonId" element={<LessonPage />} />
|
<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 */}
|
{/* Existing Routes */}
|
||||||
<Route path="/search" element={<SearxngPage />} />
|
<Route path="/search" element={<SearxngPage />} />
|
||||||
|
|||||||
@ -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 { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import {
|
import {
|
||||||
@ -19,9 +19,6 @@ import Login from '@mui/icons-material/Login';
|
|||||||
import Logout from '@mui/icons-material/Logout';
|
import Logout from '@mui/icons-material/Logout';
|
||||||
import Teacher from '@mui/icons-material/School';
|
import Teacher from '@mui/icons-material/School';
|
||||||
import Student from '@mui/icons-material/Person';
|
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 Calendar from '@mui/icons-material/CalendarToday';
|
||||||
import TeacherPlanner from '@mui/icons-material/Assignment';
|
import TeacherPlanner from '@mui/icons-material/Assignment';
|
||||||
import ExamMarker from '@mui/icons-material/AssignmentTurnedIn';
|
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 Class from '@mui/icons-material/Class';
|
||||||
import Book from '@mui/icons-material/Book';
|
import Book from '@mui/icons-material/Book';
|
||||||
import Enrollment from '@mui/icons-material/HowToReg';
|
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 { HEADER_HEIGHT } from './Layout';
|
||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { GraphNavigator } from '../components/navigation/GraphNavigator';
|
import { GraphNavigator } from '../components/navigation/GraphNavigator';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut, accessToken } = useAuth();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
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 [isAuthenticated, setIsAuthenticated] = useState(!!user);
|
||||||
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
|
|
||||||
const showGraphNavigation = location.pathname === '/single-player';
|
const showGraphNavigation = location.pathname === '/single-player';
|
||||||
|
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
|
||||||
|
|
||||||
// Update authentication state whenever user changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newAuthState = !!user;
|
setIsAuthenticated(!!user);
|
||||||
setIsAuthenticated(newAuthState);
|
}, [user]);
|
||||||
logger.debug('user-context', '🔄 User state changed in header', {
|
|
||||||
hasUser: newAuthState,
|
// Check platform admin status and school role once on login
|
||||||
userId: user?.id,
|
const checkAdminStatus = useCallback(async () => {
|
||||||
userEmail: user?.email,
|
if (!accessToken) return;
|
||||||
userState: newAuthState ? 'logged-in' : 'logged-out',
|
// Platform admin check
|
||||||
isAdmin
|
try {
|
||||||
});
|
const res = await fetch(`${API_BASE}/admin/stats`, {
|
||||||
}, [user, isAdmin]);
|
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>) => {
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
@ -239,13 +265,62 @@ const Header: React.FC = () => {
|
|||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Enrollment />
|
<Enrollment />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary="Enrollment Requests"
|
primary="Enrollment Requests"
|
||||||
secondary="Review pending enrollments"
|
secondary="Review pending enrollments"
|
||||||
/>
|
/>
|
||||||
</MenuItem>,
|
</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" />,
|
<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
|
// Features Section
|
||||||
<Typography
|
<Typography
|
||||||
key="features-header"
|
key="features-header"
|
||||||
@ -311,8 +386,8 @@ const Header: React.FC = () => {
|
|||||||
<ListItemText primary="Search" />
|
<ListItemText primary="Search" />
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
|
|
||||||
// Admin Section
|
// Platform Admin Section
|
||||||
...(isAdmin ? [
|
...(isPlatformAdmin ? [
|
||||||
<Divider key="admin-divider" />,
|
<Divider key="admin-divider" />,
|
||||||
<Typography
|
<Typography
|
||||||
key="admin-header"
|
key="admin-header"
|
||||||
@ -330,10 +405,8 @@ const Header: React.FC = () => {
|
|||||||
Administration
|
Administration
|
||||||
</Typography>,
|
</Typography>,
|
||||||
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
|
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon><Admin /></ListItemIcon>
|
||||||
<Admin />
|
<ListItemText primary="Platform Admin" secondary="Schools & system stats" />
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Admin Dashboard" />
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
] : []),
|
] : []),
|
||||||
|
|
||||||
|
|||||||
167
src/pages/auth/PlatformAdminPage.tsx
Normal file
167
src/pages/auth/PlatformAdminPage.tsx
Normal 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;
|
||||||
@ -1,324 +1,433 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { AccessTime, Add, ArrowBack, CalendarToday, Delete, Edit, MenuBook, People } from '@mui/icons-material';
|
import {
|
||||||
import useTimetableStore from '../../stores/timetableStore';
|
Box, Typography, Button, CircularProgress, Alert, Chip, Divider,
|
||||||
import { useUser } from '../../contexts/UserContext';
|
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
||||||
import Modal from '../../components/common/Modal';
|
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 ClassDetailPage: React.FC = () => {
|
||||||
const { classId } = useParams<{ classId: string }>();
|
const { classId } = useParams<{ classId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { profile } = useUser();
|
const { accessToken, user } = useAuth();
|
||||||
const {
|
|
||||||
currentClass,
|
|
||||||
timetables,
|
|
||||||
enrolledStudents,
|
|
||||||
classTeachers,
|
|
||||||
classDetailLoading,
|
|
||||||
classDetailError,
|
|
||||||
fetchClassDetail,
|
|
||||||
deleteClass,
|
|
||||||
clearCurrentClass,
|
|
||||||
} = useTimetableStore();
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'timetables' | 'students' | 'teachers'>('timetables');
|
const [cls, setCls] = useState<ClassDetail | null>(null);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
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(() => {
|
const load = useCallback(async () => {
|
||||||
if (classId) {
|
if (!accessToken || !classId) return;
|
||||||
fetchClassDetail(classId);
|
setLoading(true);
|
||||||
}
|
setError(null);
|
||||||
return () => {
|
try {
|
||||||
clearCurrentClass();
|
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 () => {
|
const apiDelete = async (path: string) => {
|
||||||
if (!classId) return;
|
await fetch(`${API_BASE}${path}`, {
|
||||||
await deleteClass(classId);
|
method: 'DELETE',
|
||||||
navigate('/timetable/classes');
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
};
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const isOwner = currentClass?.created_by === profile?.id;
|
const apiPatch = async (path: string, body: object) => {
|
||||||
const isTeacher = classTeachers.some(t => t.teacher_id === profile?.id && t.is_primary);
|
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 (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
{/* Header */}
|
||||||
</div>
|
<Button
|
||||||
);
|
size="small"
|
||||||
}
|
startIcon={<ArrowBack />}
|
||||||
|
onClick={() => navigate('/classes')}
|
||||||
if (classDetailError || !currentClass) {
|
sx={{ mb: 2 }}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Back to Classes
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
onClick={handleDeleteClass}
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
|
||||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
<Box>
|
||||||
>
|
<Typography variant="h5" fontWeight={700}>{cls.name}</Typography>
|
||||||
Delete Class
|
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
</button>
|
{cls.class_code && <Chip label={cls.class_code} size="small" />}
|
||||||
</div>
|
{cls.subject && <Chip label={cls.subject} size="small" variant="outlined" />}
|
||||||
</div>
|
{cls.year_group && <Chip label={`Y${cls.year_group}`} size="small" variant="outlined" />}
|
||||||
</Modal>
|
<Chip
|
||||||
</div>
|
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;
|
export default ClassDetailPage;
|
||||||
|
|||||||
322
src/pages/timetable/SchoolSettingsPage.tsx
Normal file
322
src/pages/timetable/SchoolSettingsPage.tsx
Normal 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;
|
||||||
280
src/pages/timetable/StaffManagerPage.tsx
Normal file
280
src/pages/timetable/StaffManagerPage.tsx
Normal 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;
|
||||||
208
src/pages/timetable/StudentLessonsPage.tsx
Normal file
208
src/pages/timetable/StudentLessonsPage.tsx
Normal 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;
|
||||||
380
src/pages/timetable/StudentManagerPage.tsx
Normal file
380
src/pages/timetable/StudentManagerPage.tsx
Normal 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;
|
||||||
389
src/pages/timetable/TaughtLessonsPage.tsx
Normal file
389
src/pages/timetable/TaughtLessonsPage.tsx
Normal 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;
|
||||||
@ -1,19 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Timetable Module - Page Exports
|
* 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 TimetablePage } from './TimetablePage';
|
||||||
export { default as ClassesPage } from './ClassesPage';
|
export { default as ClassesPage } from './ClassesPage';
|
||||||
export { default as LessonPage } from './LessonPage';
|
export { default as LessonPage } from './LessonPage';
|
||||||
|
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
|
||||||
// Class management pages
|
|
||||||
export { default as MyClassesPage } from './MyClassesPage';
|
export { default as MyClassesPage } from './MyClassesPage';
|
||||||
export { default as EnrollmentRequestsPage } from './EnrollmentRequestsPage';
|
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';
|
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';
|
||||||
|
|||||||
@ -170,8 +170,12 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
|
|||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!isSection) {
|
if (!isSection) {
|
||||||
onSelect(node);
|
onSelect(node);
|
||||||
} else if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) {
|
} else {
|
||||||
handleToggle({ stopPropagation: () => {} } as React.MouseEvent);
|
// 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]);
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
const handleSelect = useCallback((node: TreeNode) => {
|
const handleSelect = useCallback((node: TreeNode) => {
|
||||||
if (!node.is_section) navigateToNeoNode(node);
|
if (!node.is_section || node.neo4j_node_id) navigateToNeoNode(node);
|
||||||
}, [navigateToNeoNode]);
|
}, [navigateToNeoNode]);
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
Button, Stepper, Step, StepLabel, Box, TextField,
|
Button, Stepper, Step, StepLabel, Box, TextField,
|
||||||
Typography, IconButton, Select, MenuItem, FormControl,
|
Typography, IconButton, Select, MenuItem, FormControl,
|
||||||
InputLabel, CircularProgress, Alert, Divider,
|
InputLabel, CircularProgress, Alert, Divider, Chip,
|
||||||
|
Tooltip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||||
import { useAuth } from '../../../../../../contexts/AuthContext';
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
@ -13,6 +14,7 @@ interface TermInput {
|
|||||||
term_number: number;
|
term_number: number;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeriodInput {
|
interface PeriodInput {
|
||||||
@ -20,7 +22,21 @@ interface PeriodInput {
|
|||||||
name: string;
|
name: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_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 {
|
export interface SchoolInfo {
|
||||||
@ -34,9 +50,14 @@ export interface SchoolInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TERMS: TermInput[] = [
|
const DEFAULT_TERMS: TermInput[] = [
|
||||||
{ name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19' },
|
{ 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' },
|
{ 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' },
|
{ 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[] = [
|
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' },
|
{ 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 {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -64,21 +128,33 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 0
|
||||||
const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || '');
|
const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || '');
|
||||||
const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || '');
|
const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || '');
|
||||||
const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || '');
|
const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || '');
|
||||||
|
|
||||||
|
// Step 1
|
||||||
const [yearStart, setYearStart] = useState('2025-09-01');
|
const [yearStart, setYearStart] = useState('2025-09-01');
|
||||||
const [yearEnd, setYearEnd] = useState('2026-07-31');
|
const [yearEnd, setYearEnd] = useState('2026-07-31');
|
||||||
const [terms, setTerms] = useState<TermInput[]>(DEFAULT_TERMS);
|
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);
|
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, {
|
const addTerm = () => setTerms(prev => [...prev, {
|
||||||
name: `Term ${prev.length + 1}`,
|
name: `Term ${prev.length + 1}`,
|
||||||
term_number: prev.length + 1,
|
term_number: prev.length + 1,
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
|
notes: '',
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
const removeTerm = (i: number) => setTerms(prev =>
|
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) =>
|
const updateTerm = (i: number, field: keyof TermInput, value: string) =>
|
||||||
setTerms(prev => prev.map((t, idx) => idx === i ? { ...t, [field]: value } : t));
|
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, {
|
const addPeriod = () => setPeriods(prev => [...prev, {
|
||||||
code: `P${prev.length + 1}`,
|
code: `P${prev.length + 1}`,
|
||||||
name: `Period ${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) =>
|
const updatePeriod = (i: number, field: keyof PeriodInput, value: string) =>
|
||||||
setPeriods(prev => prev.map((p, idx) => idx === i ? { ...p, [field]: value } : p));
|
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 () => {
|
const handleSaveSchoolInfo = async () => {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@ -124,23 +237,56 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Step 4: final save ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleSaveCalendar = async () => {
|
const handleSaveCalendar = async () => {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
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();
|
const setupData = await setupRes.json();
|
||||||
if (data.status === 'ok') {
|
if (setupData.status !== 'ok') {
|
||||||
onComplete();
|
setError(setupData.message || 'Calendar setup failed');
|
||||||
handleClose();
|
return;
|
||||||
} else {
|
|
||||||
setError(data.message || 'Calendar setup failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@ -157,7 +303,18 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
|||||||
const addr = schoolInfo.address || {};
|
const addr = schoolInfo.address || {};
|
||||||
const addressStr = [addr.street, addr.town, addr.county, addr.postcode].filter(Boolean).join(', ');
|
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 (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
@ -171,6 +328,7 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{/* ── Step 0: School Details ───────────────────────────────── */}
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Information</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Information</Typography>
|
||||||
@ -217,6 +375,7 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 1: Terms ────────────────────────────────────────── */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Year</Typography>
|
<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>
|
<Button size="small" startIcon={<AddIcon />} onClick={addTerm}>Add Term</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{terms.map((term, i) => (
|
{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' }}>
|
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||||
<TextField label="Term Name" value={term.name}
|
<TextField label="Break Name" value={tb.name}
|
||||||
onChange={e => updateTerm(i, 'name', e.target.value)}
|
onChange={e => updateTermBreak(i, 'name', e.target.value)}
|
||||||
size="small" sx={{ width: 140 }} />
|
size="small" sx={{ width: 180 }}
|
||||||
<TextField label="Start Date" type="date" value={term.start_date}
|
placeholder="e.g. Christmas Break" />
|
||||||
onChange={e => updateTerm(i, 'start_date', e.target.value)}
|
<TextField label="Start Date" type="date" value={tb.start_date}
|
||||||
|
onChange={e => updateTermBreak(i, 'start_date', e.target.value)}
|
||||||
size="small" InputLabelProps={{ shrink: true }} />
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
<TextField label="End Date" type="date" value={term.end_date}
|
<TextField label="End Date" type="date" value={tb.end_date}
|
||||||
onChange={e => updateTerm(i, 'end_date', e.target.value)}
|
onChange={e => updateTermBreak(i, 'end_date', e.target.value)}
|
||||||
size="small" InputLabelProps={{ shrink: true }} />
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
<IconButton size="small" onClick={() => removeTerm(i)}>
|
<IconButton size="small" onClick={() => removeTermBreak(i)}>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
@ -252,7 +459,8 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{/* ── Step 3: Daily Periods ────────────────────────────────── */}
|
||||||
|
{step === 3 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||||
<Typography variant="subtitle2">Daily Period Schedule</Typography>
|
<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}
|
<TextField label="End" type="time" value={p.end_time}
|
||||||
onChange={e => updatePeriod(i, 'end_time', e.target.value)}
|
onChange={e => updatePeriod(i, 'end_time', e.target.value)}
|
||||||
size="small" InputLabelProps={{ shrink: true }} />
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
<FormControl size="small" sx={{ width: 130 }}>
|
<FormControl size="small" sx={{ width: 150 }}>
|
||||||
<InputLabel>Type</InputLabel>
|
<InputLabel>Type</InputLabel>
|
||||||
<Select label="Type" value={p.period_type}
|
<Select label="Type" value={p.period_type}
|
||||||
onChange={e => updatePeriod(i, 'period_type', e.target.value)}>
|
onChange={e => updatePeriod(i, 'period_type', e.target.value)}>
|
||||||
<MenuItem value="lesson">Lesson</MenuItem>
|
<MenuItem value="lesson">Lesson</MenuItem>
|
||||||
<MenuItem value="break">Break</MenuItem>
|
<MenuItem value="break">Break</MenuItem>
|
||||||
<MenuItem value="registration">Registration</MenuItem>
|
<MenuItem value="registration">Registration</MenuItem>
|
||||||
|
<MenuItem value="offtimetable">Off-timetable</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<IconButton size="small" onClick={() => removePeriod(i)}>
|
<IconButton size="small" onClick={() => removePeriod(i)}>
|
||||||
@ -288,6 +497,55 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</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>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
@ -302,10 +560,20 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
|||||||
)}
|
)}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<Button onClick={() => setStep(2)} variant="outlined" disabled={saving}>
|
<Button onClick={() => setStep(2)} variant="outlined" disabled={saving}>
|
||||||
Next: Daily Periods
|
Next: Term Breaks
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{step === 2 && (
|
{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}>
|
<Button onClick={handleSaveCalendar} variant="contained" disabled={saving}>
|
||||||
{saving ? <CircularProgress size={18} /> : 'Save School Calendar'}
|
{saving ? <CircularProgress size={18} /> : 'Save School Calendar'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
Button, Box, TextField, Typography, Table, TableHead,
|
Button, Box, Typography, Table, TableHead,
|
||||||
TableBody, TableRow, TableCell, CircularProgress, Alert,
|
TableBody, TableRow, TableCell, CircularProgress, Alert,
|
||||||
|
Autocomplete, TextField,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useAuth } from '../../../../../../contexts/AuthContext';
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
|
|
||||||
@ -14,6 +15,14 @@ export interface PeriodTemplate {
|
|||||||
period_type: string;
|
period_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClassOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
class_code?: string;
|
||||||
|
subject?: string;
|
||||||
|
year_group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
|
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
|
||||||
|
|
||||||
function emptyGrid(): Record<string, Record<string, string>> {
|
function emptyGrid(): Record<string, Record<string, string>> {
|
||||||
@ -22,6 +31,13 @@ function emptyGrid(): Record<string, Record<string, string>> {
|
|||||||
return g;
|
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 {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -43,9 +59,11 @@ export function TeacherTimetableWizard({
|
|||||||
const [localTimetableId, setLocalTimetableId] = useState<string | null>(initialTimetableId);
|
const [localTimetableId, setLocalTimetableId] = useState<string | null>(initialTimetableId);
|
||||||
const [initializing, setInitializing] = useState(false);
|
const [initializing, setInitializing] = useState(false);
|
||||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||||
|
const [loadingClasses, setLoadingClasses] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [grid, setGrid] = useState<Record<string, Record<string, string>>>(emptyGrid);
|
const [grid, setGrid] = useState<Record<string, Record<string, string>>>(emptyGrid);
|
||||||
|
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
|
||||||
const slotsLoadedRef = useRef(false);
|
const slotsLoadedRef = useRef(false);
|
||||||
|
|
||||||
const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson');
|
const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson');
|
||||||
@ -63,6 +81,23 @@ export function TeacherTimetableWizard({
|
|||||||
slotsLoadedRef.current = false;
|
slotsLoadedRef.current = false;
|
||||||
}, [open, initialTimetableId]);
|
}, [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
|
// Auto-create TeacherTimetable node if not yet done
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || localTimetableId || !accessToken || initializing) return;
|
if (!open || localTimetableId || !accessToken || initializing) return;
|
||||||
@ -153,6 +188,7 @@ export function TeacherTimetableWizard({
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setClassOptions([]);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -178,16 +214,21 @@ export function TeacherTimetableWizard({
|
|||||||
|
|
||||||
{!initializing && !loadingSlots && localTimetableId && (
|
{!initializing && !loadingSlots && localTimetableId && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
Enter your class codes for each lesson slot (leave blank if free)
|
Select or type a class name for each lesson slot (leave blank if free)
|
||||||
</Typography>
|
</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' }}>
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 600, minWidth: 100 }}>Period</TableCell>
|
<TableCell sx={{ fontWeight: 600, minWidth: 100 }}>Period</TableCell>
|
||||||
{DAYS.map(d => (
|
{DAYS.map(d => (
|
||||||
<TableCell key={d} align="center" sx={{ fontWeight: 600, minWidth: 110 }}>
|
<TableCell key={d} align="center" sx={{ fontWeight: 600, minWidth: 130 }}>
|
||||||
{d}
|
{d}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
@ -208,15 +249,42 @@ export function TeacherTimetableWizard({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
{DAYS.map(day => (
|
{DAYS.map(day => (
|
||||||
<TableCell key={day} align="center" sx={{ p: 0.5 }}>
|
<TableCell key={day} align="center" sx={{ p: 0.5 }}>
|
||||||
<TextField
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="—"
|
options={classOptions}
|
||||||
|
getOptionLabel={opt =>
|
||||||
|
typeof opt === 'string' ? opt : classLabel(opt)
|
||||||
|
}
|
||||||
value={grid[day]?.[period.code] || ''}
|
value={grid[day]?.[period.code] || ''}
|
||||||
onChange={e => setCell(day, period.code, e.target.value)}
|
onChange={(_, val) => {
|
||||||
inputProps={{
|
const text =
|
||||||
style: { textAlign: 'center', fontSize: '0.8rem', padding: '4px 6px' },
|
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>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user