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
+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;