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 <[email protected]>
This commit is contained in:
2026-05-27 02:56:04 +01:00
co-authored by Claude Sonnet 4.6
parent 138dfb1531
commit 6ac5ab7b5c
13 changed files with 2663 additions and 384 deletions
+98 -25
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import {
@@ -19,9 +19,6 @@ import Login from '@mui/icons-material/Login';
import Logout from '@mui/icons-material/Logout';
import Teacher from '@mui/icons-material/School';
import Student from '@mui/icons-material/Person';
import TLDrawDev from '@mui/icons-material/Dashboard';
import DevTools from '@mui/icons-material/Build';
import Multiplayer from '@mui/icons-material/Groups';
import Calendar from '@mui/icons-material/CalendarToday';
import TeacherPlanner from '@mui/icons-material/Assignment';
import ExamMarker from '@mui/icons-material/AssignmentTurnedIn';
@@ -33,32 +30,61 @@ import Schedule from '@mui/icons-material/Schedule';
import Class from '@mui/icons-material/Class';
import Book from '@mui/icons-material/Book';
import Enrollment from '@mui/icons-material/HowToReg';
import Lessons from '@mui/icons-material/EventNote';
import People from '@mui/icons-material/People';
import SchoolSettings from '@mui/icons-material/Tune';
import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig';
import { GraphNavigator } from '../components/navigation/GraphNavigator';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const Header: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const { user, signOut } = useAuth();
const { user, signOut, accessToken } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [isPlatformAdmin, setIsPlatformAdmin] = useState(false);
const [schoolRole, setSchoolRole] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
const showGraphNavigation = location.pathname === '/single-player';
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
// Update authentication state whenever user changes
useEffect(() => {
const newAuthState = !!user;
setIsAuthenticated(newAuthState);
logger.debug('user-context', '🔄 User state changed in header', {
hasUser: newAuthState,
userId: user?.id,
userEmail: user?.email,
userState: newAuthState ? 'logged-in' : 'logged-out',
isAdmin
});
}, [user, isAdmin]);
setIsAuthenticated(!!user);
}, [user]);
// Check platform admin status and school role once on login
const checkAdminStatus = useCallback(async () => {
if (!accessToken) return;
// Platform admin check
try {
const res = await fetch(`${API_BASE}/admin/stats`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
setIsPlatformAdmin(res.ok);
} catch {
setIsPlatformAdmin(false);
}
// School role check
try {
const res = await fetch(`${API_BASE}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.ok) {
const data = await res.json();
setSchoolRole(data.user_role || null);
}
} catch {
setSchoolRole(null);
}
}, [accessToken]);
useEffect(() => {
if (accessToken) checkAdminStatus();
else { setIsPlatformAdmin(false); setSchoolRole(null); }
}, [accessToken, checkAdminStatus]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -239,13 +265,62 @@ const Header: React.FC = () => {
<ListItemIcon>
<Enrollment />
</ListItemIcon>
<ListItemText
primary="Enrollment Requests"
<ListItemText
primary="Enrollment Requests"
secondary="Review pending enrollments"
/>
</MenuItem>,
<MenuItem key="my-lessons" onClick={() => handleNavigation('/my-lessons')}>
<ListItemIcon>
<Lessons />
</ListItemIcon>
<ListItemText
primary="My Lessons"
secondary="Weekly lesson view"
/>
</MenuItem>,
<MenuItem key="student-lessons" onClick={() => handleNavigation('/student-lessons')}>
<ListItemIcon>
<Student />
</ListItemIcon>
<ListItemText
primary="Student Lessons"
secondary="Your class timetable"
/>
</MenuItem>,
<Divider key="timetable-divider" />,
// School Admin Section
...(isSchoolAdmin ? [
<Typography
key="school-admin-header"
variant="subtitle2"
sx={{
px: 2, py: 1,
color: theme.palette.primary.main,
fontWeight: 600,
letterSpacing: '0.5px',
textTransform: 'uppercase',
fontSize: '0.75rem',
}}
>
School Admin
</Typography>,
<MenuItem key="school-settings" onClick={() => handleNavigation('/school-settings')}>
<ListItemIcon><SchoolSettings /></ListItemIcon>
<ListItemText primary="School Settings" secondary="Calendar, overview" />
</MenuItem>,
<MenuItem key="staff-manager" onClick={() => handleNavigation('/staff-manager')}>
<ListItemIcon><People /></ListItemIcon>
<ListItemText primary="Staff Manager" secondary="Invite & manage teachers" />
</MenuItem>,
<MenuItem key="student-manager" onClick={() => handleNavigation('/student-manager')}>
<ListItemIcon><Teacher /></ListItemIcon>
<ListItemText primary="Student Manager" secondary="Invite & manage students" />
</MenuItem>,
<Divider key="school-admin-divider" />,
] : []),
// Features Section
<Typography
key="features-header"
@@ -311,8 +386,8 @@ const Header: React.FC = () => {
<ListItemText primary="Search" />
</MenuItem>,
// Admin Section
...(isAdmin ? [
// Platform Admin Section
...(isPlatformAdmin ? [
<Divider key="admin-divider" />,
<Typography
key="admin-header"
@@ -330,10 +405,8 @@ const Header: React.FC = () => {
Administration
</Typography>,
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
<ListItemIcon>
<Admin />
</ListItemIcon>
<ListItemText primary="Admin Dashboard" />
<ListItemIcon><Admin /></ListItemIcon>
<ListItemText primary="Platform Admin" secondary="Schools & system stats" />
</MenuItem>
] : []),
+167
View File
@@ -0,0 +1,167 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
Table, TableHead, TableBody, TableRow, TableCell,
Card, CardContent, Grid, IconButton, Tooltip,
} from '@mui/material';
import { Refresh, School, People, EventNote, HourglassEmpty } from '@mui/icons-material';
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
interface SchoolEntry {
id: string;
name: string;
urn: string | null;
website: string | null;
status: string;
created_at: string;
neo4j_uuid_string: string | null;
staff_count: number;
student_count: number;
has_calendar: boolean;
pending_invitations: number;
}
interface Stats {
schools: number;
profiles: number;
taught_lessons: number;
pending_invitations: number;
}
function StatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ElementType }) {
return (
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
</Box>
</CardContent>
</Card>
);
}
const PlatformAdminPage: React.FC = () => {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [schools, setSchools] = useState<SchoolEntry[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const headers = { Authorization: `Bearer ${accessToken}` };
const loadData = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const [schoolsRes, statsRes] = await Promise.all([
fetch(`${API_BASE}/admin/schools`, { headers }).then(r => r.json()),
fetch(`${API_BASE}/admin/stats`, { headers }).then(r => r.json()),
]);
if (schoolsRes.status === 'ok') setSchools(schoolsRes.schools || []);
else setError(schoolsRes.detail || 'Failed to load schools');
if (statsRes.status === 'ok') setStats(statsRes);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadData(); }, [loadData]);
return (
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Platform Admin</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Classroom Copilot system overview</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
</Tooltip>
<Button size="small" variant="outlined" onClick={() => navigate('/dashboard')}> Dashboard</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{stats && (
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={3}><StatCard label="Schools" value={stats.schools} icon={School} /></Grid>
<Grid item xs={6} sm={3}><StatCard label="Profiles" value={stats.profiles} icon={People} /></Grid>
<Grid item xs={6} sm={3}><StatCard label="Taught Lessons" value={stats.taught_lessons} icon={EventNote} /></Grid>
<Grid item xs={6} sm={3}><StatCard label="Pending Invites" value={stats.pending_invitations} icon={HourglassEmpty} /></Grid>
</Grid>
)}
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : schools.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No schools registered.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>School</TableCell>
<TableCell>URN</TableCell>
<TableCell>Staff</TableCell>
<TableCell>Students</TableCell>
<TableCell>Calendar</TableCell>
<TableCell>Invites</TableCell>
<TableCell>Neo4j</TableCell>
<TableCell>Registered</TableCell>
</TableRow>
</TableHead>
<TableBody>
{schools.map(s => (
<TableRow key={s.id}>
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>{s.name}</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
{s.urn || '—'}
</TableCell>
<TableCell>{s.staff_count}</TableCell>
<TableCell>{s.student_count}</TableCell>
<TableCell>
<Chip
label={s.has_calendar ? 'Yes' : 'No'}
size="small"
color={s.has_calendar ? 'success' : 'default'}
sx={{ fontSize: '0.65rem', height: 18 }}
/>
</TableCell>
<TableCell>
{s.pending_invitations > 0
? <Chip label={s.pending_invitations} size="small" color="warning" sx={{ fontSize: '0.65rem', height: 18 }} />
: <Typography variant="caption" sx={{ color: 'text.disabled' }}></Typography>}
</TableCell>
<TableCell>
<Chip
label={s.neo4j_uuid_string ? 'Provisioned' : 'Pending'}
size="small"
color={s.neo4j_uuid_string ? 'success' : 'warning'}
sx={{ fontSize: '0.65rem', height: 18 }}
/>
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(s.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
);
};
export default PlatformAdminPage;
+420 -311
View File
@@ -1,324 +1,433 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { AccessTime, Add, ArrowBack, CalendarToday, Delete, Edit, MenuBook, People } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore';
import { useUser } from '../../contexts/UserContext';
import Modal from '../../components/common/Modal';
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, CircularProgress, Alert, Chip, Divider,
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
Autocomplete, IconButton, Tooltip, Tabs, Tab, Avatar,
} from '@mui/material';
import {
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Profile { id: string; full_name: string; display_name?: string; email: string; }
interface ClassTeacher { teacher_id: string; is_primary: boolean; can_edit: boolean; profile: Profile; }
interface ClassStudent { student_id: string; status: string; enrolled_at: string; profile: Profile; }
interface EnrollmentRequest { id: string; student_id: string; status: string; created_at: string; profile: Profile; }
interface ClassDetail {
id: string;
name: string;
class_code?: string;
subject?: string;
year_group?: string;
description?: string;
is_active: boolean;
teachers: ClassTeacher[];
students: ClassStudent[];
enrollment_requests: EnrollmentRequest[];
student_count: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function initials(name: string) {
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
}
// ─── Add Student Dialog ───────────────────────────────────────────────────────
interface AddStudentDialogProps {
open: boolean;
onClose: () => void;
onAdd: (studentId: string) => Promise<void>;
accessToken: string;
existingIds: Set<string>;
}
function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: AddStudentDialogProps) {
const [allStudents, setAllStudents] = useState<Profile[]>([]);
const [selected, setSelected] = useState<Profile | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setLoading(true);
fetch(`${API_BASE}/classes/school/students`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then(r => r.json())
.then(d => setAllStudents((d.students || []).filter((s: Profile) => !existingIds.has(s.id))))
.finally(() => setLoading(false));
}, [open, accessToken, existingIds]);
const handleAdd = async () => {
if (!selected) return;
setSaving(true);
await onAdd(selected.id);
setSaving(false);
setSelected(null);
onClose();
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Add Student to Class</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={28} />
</Box>
) : (
<Autocomplete
options={allStudents}
getOptionLabel={o => `${o.full_name} (${o.email})`}
value={selected}
onChange={(_, v) => setSelected(v)}
renderInput={params => (
<TextField {...params} label="Search students" size="small" autoFocus />
)}
sx={{ mt: 1 }}
/>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button
onClick={handleAdd}
variant="contained"
disabled={!selected || saving}
startIcon={saving ? <CircularProgress size={16} /> : <PersonAdd />}
>
Add
</Button>
</DialogActions>
</Dialog>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const ClassDetailPage: React.FC = () => {
const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate();
const { profile } = useUser();
const {
currentClass,
timetables,
enrolledStudents,
classTeachers,
classDetailLoading,
classDetailError,
fetchClassDetail,
deleteClass,
clearCurrentClass,
} = useTimetableStore();
const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate();
const { accessToken, user } = useAuth();
const [activeTab, setActiveTab] = useState<'timetables' | 'students' | 'teachers'>('timetables');
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [cls, setCls] = useState<ClassDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState(0);
const [isAdmin, setIsAdmin] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
if (classId) {
fetchClassDetail(classId);
}
return () => {
clearCurrentClass();
const load = useCallback(async () => {
if (!accessToken || !classId) return;
setLoading(true);
setError(null);
try {
const [clsRes, roleRes] = await Promise.all([
fetch(`${API_BASE}/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json()),
fetch(`${API_BASE}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json()),
]);
if (clsRes.id) setCls(clsRes);
else setError(clsRes.detail || 'Class not found');
const role = roleRes.user_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken, classId]);
useEffect(() => { load(); }, [load]);
const apiPost = async (path: string, body?: object) => {
const r = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
return r.json();
};
}, [classId, fetchClassDetail, clearCurrentClass]);
const handleDeleteClass = async () => {
if (!classId) return;
await deleteClass(classId);
navigate('/timetable/classes');
};
const apiDelete = async (path: string) => {
await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
};
const isOwner = currentClass?.created_by === profile?.id;
const isTeacher = classTeachers.some(t => t.teacher_id === profile?.id && t.is_primary);
const apiPatch = async (path: string, body: object) => {
const r = await fetch(`${API_BASE}${path}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
};
const handleAddStudent = async (studentId: string) => {
setActionError(null);
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Failed to add student');
};
const handleRemoveStudent = async (studentId: string) => {
setActionError(null);
await apiDelete(`/classes/${classId}/students/${studentId}`);
load();
};
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
setActionError(null);
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Action failed');
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
if (error || !cls) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{error || 'Class not found'}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/classes')}>
Back to Classes
</Button>
</Box>
);
}
const existingStudentIds = new Set(cls.students.map(s => s.student_id));
const pendingCount = cls.enrollment_requests.length;
if (classDetailLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (classDetailError || !currentClass) {
return (
<div className="container mx-auto px-4 py-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-red-800 mb-2">Error Loading Class</h2>
<p className="text-red-600">{classDetailError || 'Class not found'}</p>
<Link to="/timetable/classes" className="text-blue-600 hover:underline mt-4 inline-block">
Back to Classes
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6 max-w-6xl">
{/* Header */}
<div className="mb-6">
<Link
to="/timetable/classes"
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowBack size={18} />
Back to Classes
</Link>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-gray-900">{currentClass.name}</h1>
<span className="px-3 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded-full">
{currentClass.subject}
</span>
</div>
<p className="text-gray-500">
{currentClass.school_year} {currentClass.academic_term}
</p>
</div>
{(isOwner || isTeacher) && (
<div className="flex items-center gap-2">
<Link
to={`/timetable/classes/${classId}/edit`}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
<Edit size={18} />
Edit
</Link>
<button
onClick={() => setShowDeleteModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
>
<Delete size={18} />
Delete
</button>
</div>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<CalendarToday className="text-blue-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Timetables</p>
<p className="text-xl font-semibold text-gray-900">{currentClass.timetable_count}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-50 rounded-lg">
<People className="text-green-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Students</p>
<p className="text-xl font-semibold text-gray-900">{currentClass.student_count}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 rounded-lg">
<MenuBook className="text-purple-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Teachers</p>
<p className="text-xl font-semibold text-gray-900">{classTeachers.length}</p>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="flex gap-6">
<button
onClick={() => setActiveTab('timetables')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'timetables'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Timetables
</button>
<button
onClick={() => setActiveTab('students')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'students'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Students ({enrolledStudents.length})
</button>
<button
onClick={() => setActiveTab('teachers')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'teachers'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Teachers ({classTeachers.length})
</button>
</div>
</div>
{/* Tab Content */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
{activeTab === 'timetables' && (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Timetables</h2>
{(isOwner || isTeacher) && (
<Link
to={`/timetable/classes/${classId}/timetables/new`}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Add size={18} />
Add Timetable
</Link>
)}
</div>
{timetables.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<AccessTime size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No timetables yet</p>
<p>Create a timetable to start scheduling lessons</p>
</div>
) : (
<div className="space-y-3">
{timetables.map((timetable) => (
<Link
key={timetable.id}
to={`/timetable/timetables/${timetable.id}`}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<div>
<h3 className="font-medium text-gray-900">{timetable.name}</h3>
<p className="text-sm text-gray-500">
{timetable.lesson_count} lessons
{timetable.is_recurring && ' • Recurring'}
</p>
</div>
<ArrowBack className="rotate-180 text-gray-400" size={18} />
</Link>
))}
</div>
)}
</div>
)}
{activeTab === 'students' && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Enrolled Students</h2>
{enrolledStudents.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<People size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No students enrolled</p>
<p>Students can request enrollment or be added by teachers</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{enrolledStudents.map((student) => (
<div
key={student.user_id}
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
>
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-600 font-medium">
{student.full_name.charAt(0)}
</span>
</div>
<div>
<p className="font-medium text-gray-900">{student.full_name}</p>
<p className="text-sm text-gray-500">{student.email}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'teachers' && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Teachers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{classTeachers.map((teacher) => (
<div
key={teacher.teacher_id}
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
>
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-medium">
{teacher.full_name.charAt(0)}
</span>
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">{teacher.full_name}</p>
<p className="text-sm text-gray-500">{teacher.email}</p>
</div>
{teacher.is_primary && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
Primary
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Delete Modal */}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Delete Class"
>
<div className="p-6">
<p className="text-gray-600 mb-6">
Are you sure you want to delete "{currentClass.name}"? This action cannot be undone and will remove all timetables, lessons, and whiteboards associated with this class.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteModal(false)}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
{/* Header */}
<Button
size="small"
startIcon={<ArrowBack />}
onClick={() => navigate('/classes')}
sx={{ mb: 2 }}
>
Cancel
</button>
<button
onClick={handleDeleteClass}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Delete Class
</button>
</div>
</div>
</Modal>
</div>
);
Back to Classes
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" fontWeight={700}>{cls.name}</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
{cls.class_code && <Chip label={cls.class_code} size="small" />}
{cls.subject && <Chip label={cls.subject} size="small" variant="outlined" />}
{cls.year_group && <Chip label={`Y${cls.year_group}`} size="small" variant="outlined" />}
<Chip
label={cls.is_active ? 'Active' : 'Inactive'}
size="small"
color={cls.is_active ? 'success' : 'default'}
/>
</Box>
{cls.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{cls.description}
</Typography>
)}
</Box>
<School sx={{ color: 'primary.main', fontSize: 40, opacity: 0.3 }} />
</Box>
{actionError && (
<Alert severity="error" onClose={() => setActionError(null)} sx={{ mb: 2 }}>
{actionError}
</Alert>
)}
{/* Tabs */}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tab label={`Students (${cls.student_count})`} />
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
<Tab label={`Teachers (${cls.teachers.length})`} />
</Tabs>
{/* Students tab */}
{tab === 0 && (
<Box>
{isAdmin && (
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="small"
startIcon={<PersonAdd />}
onClick={() => setAddOpen(true)}
>
Add Student
</Button>
</Box>
)}
{cls.students.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No students enrolled yet
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.students.map(s => (
<Box
key={s.student_id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'primary.light' }}>
{initials(s.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{s.profile?.full_name || s.student_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{s.profile?.email}
</Typography>
</Box>
{isAdmin && (
<Tooltip title="Remove student">
<IconButton
size="small"
color="error"
onClick={() => handleRemoveStudent(s.student_id)}
>
<PersonRemove fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{/* Enrollment requests tab */}
{tab === 1 && (
<Box>
{cls.enrollment_requests.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No pending enrollment requests
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.enrollment_requests.map(req => (
<Box
key={req.id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'warning.light',
borderRadius: 1, bgcolor: 'warning.50',
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'warning.light' }}>
{initials(req.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{req.profile?.full_name || req.student_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{req.profile?.email} · requested{' '}
{new Date(req.created_at).toLocaleDateString('en-GB')}
</Typography>
</Box>
{isAdmin && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Approve">
<IconButton
size="small"
color="success"
onClick={() => handleEnrollmentResponse(req.id, 'approve')}
>
<CheckCircle fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Reject">
<IconButton
size="small"
color="error"
onClick={() => handleEnrollmentResponse(req.id, 'reject')}
>
<Cancel fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{/* Teachers tab */}
{tab === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.teachers.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No teachers assigned
</Typography>
) : (
cls.teachers.map(t => (
<Box
key={t.teacher_id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'secondary.light' }}>
{initials(t.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{t.profile?.full_name || t.teacher_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{t.profile?.email}
</Typography>
</Box>
{t.is_primary && (
<Chip label="Primary" size="small" color="primary" />
)}
</Box>
))
)}
</Box>
)}
<AddStudentDialog
open={addOpen}
onClose={() => setAddOpen(false)}
onAdd={handleAddStudent}
accessToken={accessToken || ''}
existingIds={existingStudentIds}
/>
</Box>
);
};
export default ClassDetailPage;
+322
View File
@@ -0,0 +1,322 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
Table, TableHead, TableBody, TableRow, TableCell,
Select, MenuItem, FormControl, Tooltip, Divider,
Accordion, AccordionSummary, AccordionDetails, Grid,
Card, CardContent, IconButton,
} from '@mui/material';
import {
ExpandMore, People, School, MenuBook, EventNote,
HourglassEmpty, Refresh, Edit,
} from '@mui/icons-material';
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const DAY_TYPE_OPTIONS = ['Academic', 'Holiday', 'Staff', 'OffTimetable'];
const DAY_TYPE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning' | 'error'> = {
Academic: 'primary',
Holiday: 'warning',
Staff: 'success',
OffTimetable: 'default',
};
interface Term {
id: string;
term_name: string;
term_number: number;
start_date: string;
end_date: string;
academic_days: number;
total_days: number;
is_current: boolean;
}
interface CalendarDay {
id: string;
date: string;
day_of_week: string;
day_type: string;
week_cycle: string;
week_number: number | null;
academic_day_number: number | null;
excluded_period_codes: string[];
}
interface Overview {
user_role: string;
counts: {
staff: number;
students: number;
classes: number;
pending_invitations: number;
};
terms: Term[];
has_calendar: boolean;
}
// ─── Stat card ────────────────────────────────────────────────────────────────
function StatCard({ label, value, icon: Icon, onClick }: {
label: string; value: number | string; icon: React.ElementType; onClick?: () => void;
}) {
return (
<Card
sx={{ cursor: onClick ? 'pointer' : 'default', '&:hover': onClick ? { bgcolor: 'action.hover' } : {} }}
onClick={onClick}
>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
</Box>
</CardContent>
</Card>
);
}
// ─── Calendar days table ──────────────────────────────────────────────────────
function CalendarDaysTable({ termId, headers, isAdmin }: {
termId: string; headers: Record<string, string>; isAdmin: boolean;
}) {
const [days, setDays] = useState<CalendarDay[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch(`${API_BASE}/school/calendar/days?term_id=${termId}`, { headers })
.then(r => r.json())
.then(data => setDays(data.days || []))
.catch(() => {})
.finally(() => setLoading(false));
}, [termId]);
const handleTypeChange = async (dayId: string, newType: string) => {
setSaving(dayId);
try {
await fetch(`${API_BASE}/school/calendar/days/${dayId}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ day_type: newType }),
});
setDays(prev => prev.map(d => d.id === dayId ? { ...d, day_type: newType } : d));
} catch { }
setSaving(null);
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}><CircularProgress size={20} /></Box>;
return (
<Box sx={{ overflowX: 'auto' }}>
<Table size="small" sx={{ minWidth: 500 }}>
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell>Day</TableCell>
<TableCell>Week</TableCell>
<TableCell>Cycle</TableCell>
<TableCell>Type</TableCell>
</TableRow>
</TableHead>
<TableBody>
{days.map(day => (
<TableRow key={day.id} sx={{ opacity: day.day_type !== 'Academic' ? 0.7 : 1 }}>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.78rem' }}>{day.date}</TableCell>
<TableCell sx={{ fontSize: '0.78rem' }}>{day.day_of_week.slice(0, 3)}</TableCell>
<TableCell sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{day.week_number ?? '—'}</TableCell>
<TableCell>
{day.week_cycle
? <Chip label={`W${day.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
: <Typography variant="caption" sx={{ color: 'text.disabled' }}></Typography>}
</TableCell>
<TableCell>
{isAdmin ? (
<FormControl size="small" sx={{ minWidth: 110 }}>
<Select
value={day.day_type}
onChange={e => handleTypeChange(day.id, e.target.value)}
disabled={saving === day.id}
sx={{ fontSize: '0.75rem', height: 24 }}
>
{DAY_TYPE_OPTIONS.map(opt => (
<MenuItem key={opt} value={opt} sx={{ fontSize: '0.78rem' }}>{opt}</MenuItem>
))}
</Select>
</FormControl>
) : (
<Chip
label={day.day_type}
size="small"
color={DAY_TYPE_COLORS[day.day_type] ?? 'default'}
sx={{ fontSize: '0.65rem', height: 18 }}
/>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const SchoolSettingsPage: React.FC = () => {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [overview, setOverview] = useState<Overview | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedTerm, setExpandedTerm] = useState<string | false>(false);
const headers = { Authorization: `Bearer ${accessToken}` };
const loadOverview = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/school/overview`, { headers });
const data = await res.json();
if (data.status === 'ok') setOverview(data);
else setError(data.detail || 'Failed to load school overview');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadOverview(); }, [loadOverview]);
const isAdmin = overview?.user_role === 'school_admin' || overview?.user_role === 'department_head';
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}><CircularProgress /></Box>;
return (
<Box sx={{ p: 3, maxWidth: 960, mx: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>School Settings</Typography>
{overview && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{overview.user_role}
</Typography>
)}
</Box>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadOverview}><Refresh fontSize="small" /></IconButton>
</Tooltip>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{overview && (
<>
{/* Stat cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={3}>
<StatCard label="Staff" value={overview.counts.staff} icon={People} onClick={() => navigate('/staff-manager')} />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard label="Students" value={overview.counts.students} icon={School} onClick={() => navigate('/student-manager')} />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard label="Classes" value={overview.counts.classes} icon={MenuBook} onClick={() => navigate('/classes')} />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard label="Pending invites" value={overview.counts.pending_invitations} icon={HourglassEmpty} />
</Grid>
</Grid>
<Divider sx={{ mb: 3 }} />
{/* Calendar */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>Academic Calendar</Typography>
{!overview.has_calendar && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
No calendar configured
</Typography>
)}
</Box>
{overview.terms.length === 0 ? (
<Alert severity="info">
No academic calendar set up yet. Use the School Calendar Wizard in the navigation panel to set one up.
</Alert>
) : (
overview.terms.map(term => (
<Accordion
key={term.id}
expanded={expandedTerm === term.id}
onChange={(_, open) => setExpandedTerm(open ? term.id : false)}
sx={{ mb: 0.5 }}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%', pr: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 80 }}>
{term.term_name}
</Typography>
{term.is_current && (
<Chip label="Current" size="small" color="primary" sx={{ fontSize: '0.65rem', height: 18 }} />
)}
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{term.start_date} {term.end_date}
</Typography>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
<Chip label={`${term.academic_days} teaching days`} size="small" sx={{ fontSize: '0.65rem', height: 18 }} />
{term.total_days > term.academic_days && (
<Chip
label={`${term.total_days - term.academic_days} non-teaching`}
size="small"
color="warning"
sx={{ fontSize: '0.65rem', height: 18 }}
/>
)}
</Box>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<CalendarDaysTable termId={term.id} headers={headers} isAdmin={isAdmin} />
{isAdmin && (
<Typography variant="caption" sx={{ display: 'block', px: 2, py: 1, color: 'text.secondary' }}>
Changing a day type automatically creates or removes its periods.
</Typography>
)}
</AccordionDetails>
</Accordion>
))
)}
<Divider sx={{ my: 3 }} />
{/* Quick links */}
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Manage</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Button variant="outlined" size="small" startIcon={<People />} onClick={() => navigate('/staff-manager')}>
Staff Manager
</Button>
<Button variant="outlined" size="small" startIcon={<School />} onClick={() => navigate('/student-manager')}>
Student Manager
</Button>
<Button variant="outlined" size="small" startIcon={<MenuBook />} onClick={() => navigate('/classes')}>
Classes
</Button>
<Button variant="outlined" size="small" startIcon={<EventNote />} onClick={() => navigate('/my-lessons')}>
My Lessons
</Button>
</Box>
</>
)}
</Box>
);
};
export default SchoolSettingsPage;
+280
View File
@@ -0,0 +1,280 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
TextField, Select, MenuItem, FormControl, InputLabel,
Table, TableHead, TableBody, TableRow, TableCell,
IconButton, Tooltip, Divider,
} from '@mui/material';
import {
PersonAdd, Cancel, Send, Refresh,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StaffMember {
profile_id: string;
email: string | null;
username: string | null;
display_name: string | null;
role: string;
joined_at: string;
}
interface Invitation {
id: string;
email: string;
role: string;
status: string;
created_at: string;
expires_at: string;
}
const ROLE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning'> = {
school_admin: 'primary',
department_head: 'warning',
teacher: 'success',
};
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
pending: 'warning',
accepted: 'success',
expired: 'error',
cancelled: 'default',
};
// ─── Component ───────────────────────────────────────────────────────────────
const StaffManagerPage: React.FC = () => {
const { accessToken } = useAuth();
const [staff, setStaff] = useState<StaffMember[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Invite form
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState('teacher');
const [inviting, setInviting] = useState(false);
const headers = { Authorization: `Bearer ${accessToken}` };
const loadData = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const [staffRes, invRes] = await Promise.all([
fetch(`${API_BASE}/users/staff`, { headers }),
fetch(`${API_BASE}/users/invitations?role=teacher&status=pending`, { headers })
.then(r => r.json())
.catch(() => ({ invitations: [] })),
].map((p, i) => i === 0 ? (p as Promise<Response>).then(r => r.json()) : p));
if (staffRes.status === 'ok') setStaff(staffRes.staff || []);
else setError(staffRes.detail || 'Failed to load staff');
setInvitations(invRes.invitations || []);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadData(); }, [loadData]);
const handleInvite = async () => {
if (!inviteEmail.trim() || !accessToken) return;
setInviting(true);
setError(null);
setSuccessMsg(null);
try {
const res = await fetch(`${API_BASE}/users/invite`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inviteEmail.trim().toLowerCase(), role: inviteRole }),
});
const data = await res.json();
if (data.status === 'ok' || data.status === 'already_pending') {
setSuccessMsg(
data.status === 'already_pending'
? `Invitation already pending for ${inviteEmail}`
: `Invitation sent to ${inviteEmail}`
);
setInviteEmail('');
loadData();
} else {
setError(data.detail || data.message || 'Invite failed');
}
} catch (e: any) {
setError(e.message);
} finally {
setInviting(false);
}
};
const handleCancel = async (id: string, email: string) => {
if (!accessToken) return;
try {
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
setSuccessMsg(`Cancelled invitation for ${email}`);
loadData();
} catch (e: any) {
setError(e.message);
}
};
const handleResend = async (id: string, email: string) => {
if (!accessToken) return;
try {
const res = await fetch(`${API_BASE}/users/invitations/${id}/resend`, { method: 'POST', headers });
const data = await res.json();
if (data.status === 'ok') {
setSuccessMsg(`Re-sent invitation to ${email}`);
} else {
setError(data.detail || 'Resend failed');
}
} catch (e: any) {
setError(e.message);
}
};
return (
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Staff Manager</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Invite teachers, department heads, and school admins
</Typography>
{error && <Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{successMsg && <Alert severity="success" sx={{ mt: 2 }} onClose={() => setSuccessMsg(null)}>{successMsg}</Alert>}
{/* Invite form */}
<Box sx={{ display: 'flex', gap: 1, mt: 3, alignItems: 'flex-end' }}>
<TextField
label="Email address"
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleInvite()}
size="small"
sx={{ flex: 1 }}
placeholder="[email protected]"
disabled={inviting}
/>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Role</InputLabel>
<Select label="Role" value={inviteRole} onChange={e => setInviteRole(e.target.value)} disabled={inviting}>
<MenuItem value="teacher">Teacher</MenuItem>
<MenuItem value="department_head">Department Head</MenuItem>
<MenuItem value="school_admin">School Admin</MenuItem>
</Select>
</FormControl>
<Button
variant="contained"
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
onClick={handleInvite}
disabled={inviting || !inviteEmail.trim()}
sx={{ whiteSpace: 'nowrap' }}
>
Send Invite
</Button>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadData} disabled={loading}>
<Refresh fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* Pending invitations */}
{invitations.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Pending Invitations ({invitations.length})</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Role</TableCell>
<TableCell>Status</TableCell>
<TableCell>Sent</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invitations.map(inv => (
<TableRow key={inv.id}>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{inv.email}</TableCell>
<TableCell>
<Chip label={inv.role} size="small" color={ROLE_COLORS[inv.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
</TableCell>
<TableCell>
<Chip label={inv.status} size="small" color={STATUS_COLORS[inv.status] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</TableCell>
<TableCell align="right">
<Tooltip title="Resend">
<IconButton size="small" onClick={() => handleResend(inv.id, inv.email)}>
<Send fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Cancel">
<IconButton size="small" onClick={() => handleCancel(inv.id, inv.email)}>
<Cancel fontSize="inherit" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)}
<Divider sx={{ my: 3 }} />
{/* Active staff */}
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Active Staff ({loading ? '…' : staff.length})
</Typography>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : staff.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No staff members yet.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Role</TableCell>
<TableCell>Joined</TableCell>
</TableRow>
</TableHead>
<TableBody>
{staff.map(s => (
<TableRow key={s.profile_id}>
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>
{s.display_name || s.username || '—'}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'text.secondary' }}>
{s.email || '—'}
</TableCell>
<TableCell>
<Chip label={s.role} size="small" color={ROLE_COLORS[s.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
);
};
export default StaffManagerPage;
+208
View File
@@ -0,0 +1,208 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, CircularProgress, Alert, Chip, IconButton, Tooltip,
} from '@mui/material';
import { ChevronLeft, ChevronRight, Today } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Lesson {
id: string;
date: string;
period_code: string;
period_name: string;
start_time: string | null;
end_time: string | null;
class_name: string | null;
subject: string | null;
year_group: string | null;
week_cycle: string;
day_of_week: string;
status: string;
teacher_name: string | null;
}
interface DayEntry {
date: string;
day_of_week: string;
is_today: boolean;
lessons: Lesson[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function toMonday(d: Date): string {
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
const m = new Date(d);
m.setDate(d.getDate() + diff);
return m.toISOString().slice(0, 10);
}
function addWeeks(iso: string, n: number): string {
const d = new Date(iso + 'T00:00:00');
d.setDate(d.getDate() + n * 7);
return d.toISOString().slice(0, 10);
}
function fmtDate(iso: string): string {
return new Date(iso + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
}
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
planned: 'default',
in_progress: 'primary',
completed: 'success',
cancelled: 'error',
substituted: 'warning',
};
// ─── Lesson card ──────────────────────────────────────────────────────────────
function LessonCard({ lesson }: { lesson: Lesson }) {
return (
<Box
sx={{
p: 1.25, mb: 0.75,
border: '1px solid', borderColor: 'divider', borderRadius: 1,
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
display: 'flex', flexDirection: 'column', gap: 0.5,
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{lesson.class_name || lesson.period_code}
</Typography>
{lesson.subject && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{lesson.start_time ? `${lesson.start_time}${lesson.end_time}` : lesson.period_name}
</Typography>
{lesson.week_cycle && (
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
)}
<Chip
label={lesson.status}
size="small"
color={STATUS_COLORS[lesson.status] ?? 'default'}
sx={{ height: 16, fontSize: '0.65rem' }}
/>
</Box>
{lesson.teacher_name && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem' }}>
{lesson.teacher_name}
</Typography>
)}
</Box>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const StudentLessonsPage: React.FC = () => {
const { accessToken } = useAuth();
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
const [days, setDays] = useState<DayEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (ws: string) => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE}/timetable/student/lessons?week_start=${ws}&weeks=1`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const data = await res.json();
if (data.status === 'ok') setDays(data.days);
else setError(data.message || 'Failed to load lessons');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { load(weekStart); }, [weekStart, load]);
const totalLessons = days.reduce((s, d) => s + d.lessons.length, 0);
const weekLabel = days.length > 0
? `${fmtDate(days[0].date)} ${fmtDate(days[days.length - 1].date)}`
: '';
return (
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="h5" fontWeight={700}>My Lessons</Typography>
<Typography variant="caption" color="text.secondary">
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
<Tooltip title="This week">
<IconButton size="small" onClick={() => setWeekStart(toMonday(new Date()))}>
<Today fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, -1))}>
<ChevronLeft />
</IconButton>
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, 1))}>
<ChevronRight />
</IconButton>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
{days.map(day => (
<Box key={day.date}>
<Box
sx={{
p: 1, mb: 0.75, borderRadius: 1, textAlign: 'center',
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
color: day.is_today ? 'primary.contrastText' : 'text.primary',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
{day.day_of_week.slice(0, 3)}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
{fmtDate(day.date)}
</Typography>
</Box>
{day.lessons.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
No lessons
</Typography>
) : (
day.lessons.map(lesson => (
<LessonCard key={lesson.id} lesson={lesson} />
))
)}
</Box>
))}
</Box>
)}
</Box>
);
};
export default StudentLessonsPage;
+380
View File
@@ -0,0 +1,380 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
TextField, Table, TableHead, TableBody, TableRow, TableCell,
IconButton, Tooltip, Divider, Tabs, Tab,
} from '@mui/material';
import {
PersonAdd, Cancel, Send, Refresh, UploadFile,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
interface Student {
profile_id: string;
email: string | null;
username: string | null;
display_name: string | null;
role: string;
joined_at: string;
}
interface Invitation {
id: string;
email: string;
role: string;
status: string;
created_at: string;
expires_at: string;
metadata: Record<string, any>;
}
const STATUS_COLORS: Record<string, 'default' | 'warning' | 'success' | 'error'> = {
pending: 'warning',
accepted: 'success',
expired: 'error',
cancelled: 'default',
};
const StudentManagerPage: React.FC = () => {
const { accessToken } = useAuth();
const [tab, setTab] = useState(0);
const [students, setStudents] = useState<Student[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Single invite
const [inviteEmail, setInviteEmail] = useState('');
const [inviting, setInviting] = useState(false);
// CSV import
const fileRef = useRef<HTMLInputElement>(null);
const [csvEmails, setCsvEmails] = useState<string[]>([]);
const [csvPreview, setCsvPreview] = useState(false);
const [csvProgress, setCsvProgress] = useState(0);
const [csvTotal, setCsvTotal] = useState(0);
const [bulkRunning, setBulkRunning] = useState(false);
const headers = { Authorization: `Bearer ${accessToken}` };
const loadData = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const [studRes, invRes] = await Promise.all([
fetch(`${API_BASE}/users/students`, { headers }).then(r => r.json()),
fetch(`${API_BASE}/users/invitations?role=student`, { headers }).then(r => r.json()),
]);
if (studRes.status === 'ok') setStudents(studRes.students || []);
else setError(studRes.detail || 'Failed to load students');
setInvitations(invRes.invitations || []);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadData(); }, [loadData]);
const sendInvite = async (email: string): Promise<'ok' | 'already_pending' | 'error'> => {
try {
const res = await fetch(`${API_BASE}/users/invite`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim().toLowerCase(), role: 'student' }),
});
const data = await res.json();
if (data.status === 'ok') return 'ok';
if (data.status === 'already_pending') return 'already_pending';
return 'error';
} catch {
return 'error';
}
};
const handleInvite = async () => {
if (!inviteEmail.trim() || !accessToken) return;
setInviting(true);
setError(null);
setSuccessMsg(null);
const result = await sendInvite(inviteEmail);
if (result === 'ok') {
setSuccessMsg(`Invitation sent to ${inviteEmail}`);
setInviteEmail('');
loadData();
} else if (result === 'already_pending') {
setSuccessMsg(`Invitation already pending for ${inviteEmail}`);
setInviteEmail('');
} else {
setError(`Failed to invite ${inviteEmail}`);
}
setInviting(false);
};
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const text = ev.target?.result as string;
const emails = text
.split(/[\n,;]+/)
.map(s => s.trim().toLowerCase())
.filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
setCsvEmails([...new Set(emails)]);
setCsvPreview(true);
};
reader.readAsText(file);
// Reset file input so same file can be re-selected
e.target.value = '';
};
const handleBulkInvite = async () => {
if (csvEmails.length === 0) return;
setBulkRunning(true);
setCsvTotal(csvEmails.length);
setCsvProgress(0);
let sent = 0;
let failed = 0;
for (const email of csvEmails) {
const result = await sendInvite(email);
if (result === 'ok' || result === 'already_pending') sent++;
else failed++;
setCsvProgress(prev => prev + 1);
}
setBulkRunning(false);
setCsvPreview(false);
setCsvEmails([]);
setSuccessMsg(`Bulk invite: ${sent} sent${failed > 0 ? `, ${failed} failed` : ''}`);
loadData();
};
const handleCancel = async (id: string, email: string) => {
if (!accessToken) return;
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
setSuccessMsg(`Cancelled invitation for ${email}`);
loadData();
};
const handleResend = async (id: string, email: string) => {
if (!accessToken) return;
const res = await fetch(`${API_BASE}/users/invitations/${id}/resend`, { method: 'POST', headers });
const data = await res.json();
if (data.status === 'ok') setSuccessMsg(`Re-sent invitation to ${email}`);
else setError(data.detail || 'Resend failed');
};
const pendingInvitations = invitations.filter(i => i.status === 'pending');
const otherInvitations = invitations.filter(i => i.status !== 'pending');
return (
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Student Manager</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Invite students individually or via CSV import
</Typography>
{error && <Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{successMsg && <Alert severity="success" sx={{ mt: 2 }} onClose={() => setSuccessMsg(null)}>{successMsg}</Alert>}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mt: 2, mb: 2 }}>
<Tab label="Invite" />
<Tab label={`Invitations (${invitations.length})`} />
<Tab label={`Students (${students.length})`} />
</Tabs>
{/* ── Tab 0: Invite ─────────────────────────────────────────── */}
{tab === 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Single invite</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end', mb: 3 }}>
<TextField
label="Email address"
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleInvite()}
size="small"
sx={{ flex: 1 }}
placeholder="[email protected]"
disabled={inviting}
/>
<Button
variant="contained"
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
onClick={handleInvite}
disabled={inviting || !inviteEmail.trim()}
>
Send Invite
</Button>
</Box>
<Divider sx={{ mb: 3 }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>Bulk CSV import</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 1.5 }}>
Upload a .csv or .txt file with one email per line (or comma/semicolon separated). Duplicate and invalid addresses are filtered automatically.
</Typography>
<input
ref={fileRef}
type="file"
accept=".csv,.txt"
style={{ display: 'none' }}
onChange={handleCsvFile}
/>
<Button
variant="outlined"
startIcon={<UploadFile />}
onClick={() => fileRef.current?.click()}
disabled={bulkRunning}
>
Choose File
</Button>
{csvPreview && csvEmails.length > 0 && (
<Box sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{csvEmails.length} valid email{csvEmails.length !== 1 ? 's' : ''} found
</Typography>
<Box sx={{ maxHeight: 120, overflowY: 'auto', mt: 0.5 }}>
{csvEmails.slice(0, 20).map(e => (
<Typography key={e} variant="caption" sx={{ display: 'block', fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
{e}
</Typography>
))}
{csvEmails.length > 20 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
and {csvEmails.length - 20} more
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 1.5 }}>
<Button
variant="contained"
size="small"
onClick={handleBulkInvite}
disabled={bulkRunning}
startIcon={bulkRunning ? <CircularProgress size={14} /> : <Send />}
>
{bulkRunning ? `Sending… ${csvProgress}/${csvTotal}` : `Send ${csvEmails.length} Invites`}
</Button>
<Button size="small" onClick={() => { setCsvEmails([]); setCsvPreview(false); }}>
Cancel
</Button>
</Box>
</Box>
)}
</Box>
)}
{/* ── Tab 1: Invitations ────────────────────────────────────── */}
{tab === 1 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2">All Invitations</Typography>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
</Tooltip>
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : invitations.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No invitations yet.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Status</TableCell>
<TableCell>Sent</TableCell>
<TableCell>Expires</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invitations.map(inv => (
<TableRow key={inv.id}>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{inv.email}</TableCell>
<TableCell>
<Chip label={inv.status} size="small" color={STATUS_COLORS[inv.status] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(inv.expires_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</TableCell>
<TableCell align="right">
{inv.status === 'pending' && (
<>
<Tooltip title="Resend">
<IconButton size="small" onClick={() => handleResend(inv.id, inv.email)}>
<Send fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Cancel">
<IconButton size="small" onClick={() => handleCancel(inv.id, inv.email)}>
<Cancel fontSize="inherit" />
</IconButton>
</Tooltip>
</>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
)}
{/* ── Tab 2: Students ───────────────────────────────────────── */}
{tab === 2 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2">Enrolled Students ({students.length})</Typography>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
</Tooltip>
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : students.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No enrolled students yet.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Joined</TableCell>
</TableRow>
</TableHead>
<TableBody>
{students.map(s => (
<TableRow key={s.profile_id}>
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>
{s.display_name || s.username || '—'}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'text.secondary' }}>
{s.email || '—'}
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
)}
</Box>
);
};
export default StudentManagerPage;
+389
View File
@@ -0,0 +1,389 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
Dialog, DialogTitle, DialogContent, DialogActions,
TextField, Select, MenuItem, FormControl, InputLabel,
Divider, IconButton, Tooltip,
} from '@mui/material';
import {
ChevronLeft, ChevronRight, Today, EditNote,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Lesson {
id: string;
date: string;
period_code: string;
period_name: string;
start_time: string | null;
end_time: string | null;
class_name: string | null;
subject: string | null;
year_group: string | null;
week_cycle: string;
day_of_week: string;
status: string;
lesson_plan: Record<string, any>;
notes: string | null;
whiteboard_room_id: string | null;
}
interface DayEntry {
date: string;
day_of_week: string;
is_today: boolean;
lessons: Lesson[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function toMonday(d: Date): string {
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setDate(d.getDate() + diff);
return monday.toISOString().slice(0, 10);
}
function addWeeks(isoDate: string, n: number): string {
const d = new Date(isoDate + 'T00:00:00');
d.setDate(d.getDate() + n * 7);
return d.toISOString().slice(0, 10);
}
function formatDate(isoDate: string): string {
return new Date(isoDate + 'T00:00:00').toLocaleDateString('en-GB', {
day: 'numeric', month: 'short',
});
}
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
planned: 'default',
in_progress: 'primary',
completed: 'success',
cancelled: 'error',
substituted: 'warning',
};
// ─── Lesson edit dialog ───────────────────────────────────────────────────────
interface EditDialogProps {
lesson: Lesson | null;
onClose: () => void;
onSave: (id: string, updates: { notes?: string; status?: string }) => Promise<void>;
}
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
const [notes, setNotes] = useState('');
const [status, setStatus] = useState('planned');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (lesson) {
setNotes(lesson.notes || '');
setStatus(lesson.status || 'planned');
}
}, [lesson]);
const handleSave = async () => {
if (!lesson) return;
setSaving(true);
await onSave(lesson.id, { notes, status });
setSaving(false);
onClose();
};
if (!lesson) return null;
return (
<Dialog open={!!lesson} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ pb: 1 }}>
<Box>
<Typography variant="h6">
{lesson.class_name || lesson.period_code}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{lesson.date} · {lesson.period_name}
{lesson.start_time && ` · ${lesson.start_time}${lesson.end_time}`}
{lesson.week_cycle && ` · Week ${lesson.week_cycle}`}
</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<FormControl size="small" fullWidth>
<InputLabel>Status</InputLabel>
<Select label="Status" value={status} onChange={e => setStatus(e.target.value)}>
<MenuItem value="planned">Planned</MenuItem>
<MenuItem value="in_progress">In Progress</MenuItem>
<MenuItem value="completed">Completed</MenuItem>
<MenuItem value="cancelled">Cancelled</MenuItem>
<MenuItem value="substituted">Substituted</MenuItem>
</Select>
</FormControl>
<TextField
label="Notes"
value={notes}
onChange={e => setNotes(e.target.value)}
multiline
minRows={3}
fullWidth
size="small"
placeholder="Lesson notes, objectives, reminders…"
/>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={handleSave} variant="contained" disabled={saving}>
{saving ? <CircularProgress size={18} /> : 'Save'}
</Button>
</DialogActions>
</Dialog>
);
}
// ─── Lesson card ──────────────────────────────────────────────────────────────
interface LessonCardProps {
lesson: Lesson;
onEdit: (l: Lesson) => void;
}
function LessonCard({ lesson, onEdit }: LessonCardProps) {
return (
<Box
sx={{
p: 1.25,
mb: 0.75,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{lesson.class_name || lesson.period_code}
</Typography>
<Tooltip title="Edit lesson">
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ ml: 0.5, p: 0.25 }}>
<EditNote fontSize="inherit" />
</IconButton>
</Tooltip>
</Box>
{lesson.subject && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{lesson.start_time ? `${lesson.start_time}${lesson.end_time}` : lesson.period_name}
</Typography>
{lesson.week_cycle && (
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
)}
<Chip
label={lesson.status}
size="small"
color={STATUS_COLORS[lesson.status] ?? 'default'}
sx={{ height: 16, fontSize: '0.65rem' }}
/>
</Box>
{lesson.notes && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem', fontStyle: 'italic', mt: 0.25 }}>
{lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes}
</Typography>
)}
</Box>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const TaughtLessonsPage: React.FC = () => {
const { accessToken } = useAuth();
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
const [days, setDays] = useState<DayEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [materializing, setMaterializing] = useState(false);
const [materializeResult, setMaterializeResult] = useState<string | null>(null);
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
const loadLessons = useCallback(async (ws: string) => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE}/timetable/lessons?week_start=${ws}&weeks=1`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const data = await res.json();
if (data.status === 'ok') {
setDays(data.days);
} else {
setError(data.message || 'Failed to load lessons');
}
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadLessons(weekStart); }, [weekStart, loadLessons]);
const goThisWeek = () => setWeekStart(toMonday(new Date()));
const prevWeek = () => setWeekStart(ws => addWeeks(ws, -1));
const nextWeek = () => setWeekStart(ws => addWeeks(ws, 1));
const handleMaterialize = async () => {
if (!accessToken) return;
setMaterializing(true);
setMaterializeResult(null);
try {
const res = await fetch(`${API_BASE}/timetable/materialize`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await res.json();
if (data.status === 'ok') {
setMaterializeResult(`Created ${data.lessons_upserted} lessons, ${data.whiteboard_rooms_created} rooms`);
loadLessons(weekStart);
} else {
setMaterializeResult(`Error: ${data.message}`);
}
} catch (e: any) {
setMaterializeResult(`Error: ${e.message}`);
} finally {
setMaterializing(false);
}
};
const handleSaveLesson = async (id: string, updates: { notes?: string; status?: string }) => {
if (!accessToken) return;
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
loadLessons(weekStart);
};
const totalLessons = days.reduce((sum, d) => sum + d.lessons.length, 0);
// Format week label
const weekDates = days.filter(d => d.lessons.length > 0 || true).map(d => d.date);
const weekLabel = days.length > 0
? `${formatDate(days[0].date)} ${formatDate(days[days.length - 1].date)}`
: '';
return (
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>My Lessons</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
size="small"
variant="outlined"
onClick={handleMaterialize}
disabled={materializing}
sx={{ whiteSpace: 'nowrap' }}
>
{materializing ? <CircularProgress size={14} sx={{ mr: 1 }} /> : null}
Generate Lessons
</Button>
<Tooltip title="Go to this week">
<IconButton size="small" onClick={goThisWeek}><Today fontSize="small" /></IconButton>
</Tooltip>
<IconButton size="small" onClick={prevWeek}><ChevronLeft /></IconButton>
<IconButton size="small" onClick={nextWeek}><ChevronRight /></IconButton>
</Box>
</Box>
{materializeResult && (
<Alert
severity={materializeResult.startsWith('Error') ? 'error' : 'success'}
onClose={() => setMaterializeResult(null)}
sx={{ mb: 2 }}
>
{materializeResult}
</Alert>
)}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{/* Week grid */}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
{days.map(day => (
<Box key={day.date}>
{/* Day header */}
<Box
sx={{
p: 1,
mb: 0.75,
borderRadius: 1,
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
color: day.is_today ? 'primary.contrastText' : 'text.primary',
textAlign: 'center',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
{day.day_of_week.slice(0, 3)}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
{formatDate(day.date)}
</Typography>
</Box>
{/* Lesson cards */}
{day.lessons.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
No lessons
</Typography>
) : (
day.lessons.map(lesson => (
<LessonCard
key={lesson.id}
lesson={lesson}
onEdit={setEditingLesson}
/>
))
)}
</Box>
))}
</Box>
)}
<LessonEditDialog
lesson={editingLesson}
onClose={() => setEditingLesson(null)}
onSave={handleSaveLesson}
/>
</Box>
);
};
export default TaughtLessonsPage;
+6 -8
View File
@@ -1,19 +1,17 @@
/**
* Timetable Module - Page Exports
*
* This barrel file exports all pages related to the timetable/classroom management system.
*
* @module pages/timetable
*/
// Main pages
export { default as TimetablePage } from './TimetablePage';
export { default as ClassesPage } from './ClassesPage';
export { default as LessonPage } from './LessonPage';
// Class management pages
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
export { default as MyClassesPage } from './MyClassesPage';
export { default as EnrollmentRequestsPage } from './EnrollmentRequestsPage';
export { default as StaffManagerPage } from './StaffManagerPage';
export { default as StudentManagerPage } from './StudentManagerPage';
export { default as SchoolSettingsPage } from './SchoolSettingsPage';
export { default as ClassDetailPage } from './ClassDetailPage';
export { default as StudentLessonsPage } from './StudentLessonsPage';
// Re-export types if needed
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';