From 6ac5ab7b5c9e05ba658db4eefade88f91c40888e Mon Sep 17 00:00:00 2001 From: kcar Date: Wed, 27 May 2026 02:56:04 +0100 Subject: [PATCH] feat(phase-b): student enrollment UI, teacher/student management pages, lesson views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/AppRoutes.tsx | 15 +- src/pages/Header.tsx | 123 ++- src/pages/auth/PlatformAdminPage.tsx | 167 ++++ src/pages/timetable/ClassDetailPage.tsx | 731 ++++++++++-------- src/pages/timetable/SchoolSettingsPage.tsx | 322 ++++++++ src/pages/timetable/StaffManagerPage.tsx | 280 +++++++ src/pages/timetable/StudentLessonsPage.tsx | 208 +++++ src/pages/timetable/StudentManagerPage.tsx | 380 +++++++++ src/pages/timetable/TaughtLessonsPage.tsx | 389 ++++++++++ src/pages/timetable/index.ts | 14 +- .../shared/navigation/CCGraphNavPanel.tsx | 10 +- .../navigation/SchoolCalendarWizard.tsx | 320 +++++++- .../navigation/TeacherTimetableWizard.tsx | 88 ++- 13 files changed, 2663 insertions(+), 384 deletions(-) create mode 100644 src/pages/auth/PlatformAdminPage.tsx create mode 100644 src/pages/timetable/SchoolSettingsPage.tsx create mode 100644 src/pages/timetable/StaffManagerPage.tsx create mode 100644 src/pages/timetable/StudentLessonsPage.tsx create mode 100644 src/pages/timetable/StudentManagerPage.tsx create mode 100644 src/pages/timetable/TaughtLessonsPage.tsx diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 6b9ac1d..ac3e4f3 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -12,6 +12,7 @@ import CalendarPage from './pages/user/calendarPage'; import SettingsPage from './pages/user/settingsPage'; import TLDrawCanvas from './pages/tldraw/TLDrawCanvas'; import AdminDashboard from './pages/auth/adminPage'; +import PlatformAdminPage from './pages/auth/PlatformAdminPage'; import TLDrawDevPage from './pages/tldraw/devPlayerPage'; import DevPage from './pages/tldraw/devPage'; import TeacherPlanner from './pages/react-flow/teacherPlanner'; @@ -31,8 +32,14 @@ import { TimetablePage, ClassesPage, LessonPage, + TaughtLessonsPage, MyClassesPage, EnrollmentRequestsPage, + StaffManagerPage, + StudentManagerPage, + SchoolSettingsPage, + ClassDetailPage, + StudentLessonsPage, } from './pages/timetable'; const FullContextRoutes: React.FC = () => { @@ -114,7 +121,7 @@ const AppRoutes: React.FC = () => { : + } /> @@ -125,8 +132,14 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> + } /> + } /> + } /> + } /> {/* Existing Routes */} } /> diff --git a/src/pages/Header.tsx b/src/pages/Header.tsx index 60c151f..56e5cb1 100644 --- a/src/pages/Header.tsx +++ b/src/pages/Header.tsx @@ -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); + const [isPlatformAdmin, setIsPlatformAdmin] = useState(false); + const [schoolRole, setSchoolRole] = useState(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) => { setAnchorEl(event.currentTarget); @@ -239,13 +265,62 @@ const Header: React.FC = () => { - , + handleNavigation('/my-lessons')}> + + + + + , + handleNavigation('/student-lessons')}> + + + + + , , + // School Admin Section + ...(isSchoolAdmin ? [ + + School Admin + , + handleNavigation('/school-settings')}> + + + , + handleNavigation('/staff-manager')}> + + + , + handleNavigation('/student-manager')}> + + + , + , + ] : []), + // Features Section { , - // Admin Section - ...(isAdmin ? [ + // Platform Admin Section + ...(isPlatformAdmin ? [ , { Administration , handleNavigation('/admin')}> - - - - + + ] : []), diff --git a/src/pages/auth/PlatformAdminPage.tsx b/src/pages/auth/PlatformAdminPage.tsx new file mode 100644 index 0000000..e973b00 --- /dev/null +++ b/src/pages/auth/PlatformAdminPage.tsx @@ -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 ( + + + + + {value} + {label} + + + + ); +} + +const PlatformAdminPage: React.FC = () => { + const { accessToken } = useAuth(); + const navigate = useNavigate(); + const [schools, setSchools] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + + Platform Admin + Classroom Copilot β€” system overview + + + + + + + + + + {error && setError(null)}>{error}} + + {stats && ( + + + + + + + )} + + Schools ({loading ? '…' : schools.length}) + + {loading ? ( + + ) : schools.length === 0 ? ( + No schools registered. + ) : ( + + + + School + URN + Staff + Students + Calendar + Invites + Neo4j + Registered + + + + {schools.map(s => ( + + {s.name} + + {s.urn || 'β€”'} + + {s.staff_count} + {s.student_count} + + + + + {s.pending_invitations > 0 + ? + : β€”} + + + + + + {new Date(s.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })} + + + ))} + +
+ )} +
+ ); +}; + +export default PlatformAdminPage; diff --git a/src/pages/timetable/ClassDetailPage.tsx b/src/pages/timetable/ClassDetailPage.tsx index 4d60f81..b7a4300 100644 --- a/src/pages/timetable/ClassDetailPage.tsx +++ b/src/pages/timetable/ClassDetailPage.tsx @@ -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; + accessToken: string; + existingIds: Set; +} + +function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: AddStudentDialogProps) { + const [allStudents, setAllStudents] = useState([]); + const [selected, setSelected] = useState(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 ( + + Add Student to Class + + {loading ? ( + + + + ) : ( + `${o.full_name} (${o.email})`} + value={selected} + onChange={(_, v) => setSelected(v)} + renderInput={params => ( + + )} + sx={{ mt: 1 }} + /> + )} + + + + + + + ); +} + +// ─── 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tab, setTab] = useState(0); + const [isAdmin, setIsAdmin] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [actionError, setActionError] = useState(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 ( + + + + ); + } + + if (error || !cls) { + return ( + + {error || 'Class not found'} + + + ); + } + + const existingStudentIds = new Set(cls.students.map(s => s.student_id)); + const pendingCount = cls.enrollment_requests.length; - if (classDetailLoading) { return ( -
-
-
- ); - } - - if (classDetailError || !currentClass) { - return ( -
-
-

Error Loading Class

-

{classDetailError || 'Class not found'}

- - Back to Classes - -
-
- ); - } - - return ( -
- {/* Header */} -
- - - Back to Classes - - -
-
-
-

{currentClass.name}

- - {currentClass.subject} - -
-

- {currentClass.school_year} β€’ {currentClass.academic_term} -

-
- - {(isOwner || isTeacher) && ( -
- - - Edit - - -
- )} -
-
- - {/* Stats */} -
-
-
-
- -
-
-

Timetables

-

{currentClass.timetable_count}

-
-
-
-
-
-
- -
-
-

Students

-

{currentClass.student_count}

-
-
-
-
-
-
- -
-
-

Teachers

-

{classTeachers.length}

-
-
-
-
- - {/* Tabs */} -
-
- - - -
-
- - {/* Tab Content */} -
- {activeTab === 'timetables' && ( -
-
-

Timetables

- {(isOwner || isTeacher) && ( - - - Add Timetable - - )} -
- - {timetables.length === 0 ? ( -
- -

No timetables yet

-

Create a timetable to start scheduling lessons

-
- ) : ( -
- {timetables.map((timetable) => ( - -
-

{timetable.name}

-

- {timetable.lesson_count} lessons - {timetable.is_recurring && ' β€’ Recurring'} -

-
- - - ))} -
- )} -
- )} - - {activeTab === 'students' && ( -
-

Enrolled Students

- {enrolledStudents.length === 0 ? ( -
- -

No students enrolled

-

Students can request enrollment or be added by teachers

-
- ) : ( -
- {enrolledStudents.map((student) => ( -
-
- - {student.full_name.charAt(0)} - -
-
-

{student.full_name}

-

{student.email}

-
-
- ))} -
- )} -
- )} - - {activeTab === 'teachers' && ( -
-

Teachers

-
- {classTeachers.map((teacher) => ( -
-
- - {teacher.full_name.charAt(0)} - -
-
-

{teacher.full_name}

-

{teacher.email}

-
- {teacher.is_primary && ( - - Primary - - )} -
- ))} -
-
- )} -
- - {/* Delete Modal */} - setShowDeleteModal(false)} - title="Delete Class" - > -
-

- 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. -

-
- - -
-
-
-
- ); + Back to Classes + + + + + {cls.name} + + {cls.class_code && } + {cls.subject && } + {cls.year_group && } + + + {cls.description && ( + + {cls.description} + + )} + + + + + {actionError && ( + setActionError(null)} sx={{ mb: 2 }}> + {actionError} + + )} + + {/* Tabs */} + setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}> + + 0 ? ` (${pendingCount})` : ''}`} /> + + + + {/* Students tab */} + {tab === 0 && ( + + {isAdmin && ( + + + + )} + {cls.students.length === 0 ? ( + + No students enrolled yet + + ) : ( + + {cls.students.map(s => ( + + + {initials(s.profile?.full_name || '?')} + + + + {s.profile?.full_name || s.student_id} + + + {s.profile?.email} + + + {isAdmin && ( + + handleRemoveStudent(s.student_id)} + > + + + + )} + + ))} + + )} + + )} + + {/* Enrollment requests tab */} + {tab === 1 && ( + + {cls.enrollment_requests.length === 0 ? ( + + No pending enrollment requests + + ) : ( + + {cls.enrollment_requests.map(req => ( + + + {initials(req.profile?.full_name || '?')} + + + + {req.profile?.full_name || req.student_id} + + + {req.profile?.email} Β· requested{' '} + {new Date(req.created_at).toLocaleDateString('en-GB')} + + + {isAdmin && ( + + + handleEnrollmentResponse(req.id, 'approve')} + > + + + + + handleEnrollmentResponse(req.id, 'reject')} + > + + + + + )} + + ))} + + )} + + )} + + {/* Teachers tab */} + {tab === 2 && ( + + {cls.teachers.length === 0 ? ( + + No teachers assigned + + ) : ( + cls.teachers.map(t => ( + + + {initials(t.profile?.full_name || '?')} + + + + {t.profile?.full_name || t.teacher_id} + + + {t.profile?.email} + + + {t.is_primary && ( + + )} + + )) + )} + + )} + + setAddOpen(false)} + onAdd={handleAddStudent} + accessToken={accessToken || ''} + existingIds={existingStudentIds} + /> + + ); }; export default ClassDetailPage; diff --git a/src/pages/timetable/SchoolSettingsPage.tsx b/src/pages/timetable/SchoolSettingsPage.tsx new file mode 100644 index 0000000..a62585d --- /dev/null +++ b/src/pages/timetable/SchoolSettingsPage.tsx @@ -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 = { + 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 ( + + + + + {value} + {label} + + + + ); +} + +// ─── Calendar days table ────────────────────────────────────────────────────── + +function CalendarDaysTable({ termId, headers, isAdmin }: { + termId: string; headers: Record; isAdmin: boolean; +}) { + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(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 ; + + return ( + + + + + Date + Day + Week + Cycle + Type + + + + {days.map(day => ( + + {day.date} + {day.day_of_week.slice(0, 3)} + {day.week_number ?? 'β€”'} + + {day.week_cycle + ? + : β€”} + + + {isAdmin ? ( + + + + ) : ( + + )} + + + ))} + +
+
+ ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +const SchoolSettingsPage: React.FC = () => { + const { accessToken } = useAuth(); + const navigate = useNavigate(); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedTerm, setExpandedTerm] = useState(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 ; + + return ( + + + + School Settings + {overview && ( + + {overview.user_role} + + )} + + + + + + + {error && setError(null)}>{error}} + + {overview && ( + <> + {/* Stat cards */} + + + navigate('/staff-manager')} /> + + + navigate('/student-manager')} /> + + + navigate('/classes')} /> + + + + + + + + + {/* Calendar */} + + Academic Calendar + {!overview.has_calendar && ( + + No calendar configured + + )} + + + {overview.terms.length === 0 ? ( + + No academic calendar set up yet. Use the School Calendar Wizard in the navigation panel to set one up. + + ) : ( + overview.terms.map(term => ( + setExpandedTerm(open ? term.id : false)} + sx={{ mb: 0.5 }} + > + }> + + + {term.term_name} + + {term.is_current && ( + + )} + + {term.start_date} β†’ {term.end_date} + + + + {term.total_days > term.academic_days && ( + + )} + + + + + + {isAdmin && ( + + Changing a day type automatically creates or removes its periods. + + )} + + + )) + )} + + + + {/* Quick links */} + Manage + + + + + + + + )} + + ); +}; + +export default SchoolSettingsPage; diff --git a/src/pages/timetable/StaffManagerPage.tsx b/src/pages/timetable/StaffManagerPage.tsx new file mode 100644 index 0000000..e87888b --- /dev/null +++ b/src/pages/timetable/StaffManagerPage.tsx @@ -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 = { + school_admin: 'primary', + department_head: 'warning', + teacher: 'success', +}; + +const STATUS_COLORS: Record = { + pending: 'warning', + accepted: 'success', + expired: 'error', + cancelled: 'default', +}; + +// ─── Component ─────────────────────────────────────────────────────────────── + +const StaffManagerPage: React.FC = () => { + const { accessToken } = useAuth(); + const [staff, setStaff] = useState([]); + const [invitations, setInvitations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(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).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 ( + + Staff Manager + + Invite teachers, department heads, and school admins + + + {error && setError(null)}>{error}} + {successMsg && setSuccessMsg(null)}>{successMsg}} + + {/* Invite form */} + + setInviteEmail(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleInvite()} + size="small" + sx={{ flex: 1 }} + placeholder="teacher@school.edu" + disabled={inviting} + /> + + Role + + + + + + + + + + + {/* Pending invitations */} + {invitations.length > 0 && ( + + Pending Invitations ({invitations.length}) + + + + Email + Role + Status + Sent + Actions + + + + {invitations.map(inv => ( + + {inv.email} + + + + + + + + {new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} + + + + handleResend(inv.id, inv.email)}> + + + + + handleCancel(inv.id, inv.email)}> + + + + + + ))} + +
+
+ )} + + + + {/* Active staff */} + + Active Staff ({loading ? '…' : staff.length}) + + {loading ? ( + + ) : staff.length === 0 ? ( + No staff members yet. + ) : ( + + + + Name + Email + Role + Joined + + + + {staff.map(s => ( + + + {s.display_name || s.username || 'β€”'} + + + {s.email || 'β€”'} + + + + + + {new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} + + + ))} + +
+ )} +
+ ); +}; + +export default StaffManagerPage; diff --git a/src/pages/timetable/StudentLessonsPage.tsx b/src/pages/timetable/StudentLessonsPage.tsx new file mode 100644 index 0000000..c21e3e7 --- /dev/null +++ b/src/pages/timetable/StudentLessonsPage.tsx @@ -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 = { + planned: 'default', + in_progress: 'primary', + completed: 'success', + cancelled: 'error', + substituted: 'warning', +}; + +// ─── Lesson card ────────────────────────────────────────────────────────────── + +function LessonCard({ lesson }: { lesson: Lesson }) { + return ( + + + {lesson.class_name || lesson.period_code} + + {lesson.subject && ( + + {lesson.subject}{lesson.year_group ? ` Β· Y${lesson.year_group}` : ''} + + )} + + + {lesson.start_time ? `${lesson.start_time}–${lesson.end_time}` : lesson.period_name} + + {lesson.week_cycle && ( + + )} + + + {lesson.teacher_name && ( + + {lesson.teacher_name} + + )} + + ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +const StudentLessonsPage: React.FC = () => { + const { accessToken } = useAuth(); + const [weekStart, setWeekStart] = useState(toMonday(new Date())); + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + {/* Header */} + + + My Lessons + + {weekLabel} Β· {totalLessons} lesson{totalLessons !== 1 ? 's' : ''} + + + + + setWeekStart(toMonday(new Date()))}> + + + + setWeekStart(ws => addWeeks(ws, -1))}> + + + setWeekStart(ws => addWeeks(ws, 1))}> + + + + + + {error && {error}} + + {loading ? ( + + + + ) : ( + + {days.map(day => ( + + + + {day.day_of_week.slice(0, 3)} + + + {fmtDate(day.date)} + + + {day.lessons.length === 0 ? ( + + No lessons + + ) : ( + day.lessons.map(lesson => ( + + )) + )} + + ))} + + )} + + ); +}; + +export default StudentLessonsPage; diff --git a/src/pages/timetable/StudentManagerPage.tsx b/src/pages/timetable/StudentManagerPage.tsx new file mode 100644 index 0000000..202e5c5 --- /dev/null +++ b/src/pages/timetable/StudentManagerPage.tsx @@ -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; +} + +const STATUS_COLORS: Record = { + pending: 'warning', + accepted: 'success', + expired: 'error', + cancelled: 'default', +}; + +const StudentManagerPage: React.FC = () => { + const { accessToken } = useAuth(); + const [tab, setTab] = useState(0); + const [students, setStudents] = useState([]); + const [invitations, setInvitations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + // Single invite + const [inviteEmail, setInviteEmail] = useState(''); + const [inviting, setInviting] = useState(false); + + // CSV import + const fileRef = useRef(null); + const [csvEmails, setCsvEmails] = useState([]); + 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) => { + 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 ( + + Student Manager + + Invite students individually or via CSV import + + + {error && setError(null)}>{error}} + {successMsg && setSuccessMsg(null)}>{successMsg}} + + setTab(v)} sx={{ mt: 2, mb: 2 }}> + + + + + + {/* ── Tab 0: Invite ─────────────────────────────────────────── */} + {tab === 0 && ( + + Single invite + + setInviteEmail(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleInvite()} + size="small" + sx={{ flex: 1 }} + placeholder="student@school.edu" + disabled={inviting} + /> + + + + + Bulk CSV import + + Upload a .csv or .txt file with one email per line (or comma/semicolon separated). Duplicate and invalid addresses are filtered automatically. + + + + + + {csvPreview && csvEmails.length > 0 && ( + + + {csvEmails.length} valid email{csvEmails.length !== 1 ? 's' : ''} found + + + {csvEmails.slice(0, 20).map(e => ( + + {e} + + ))} + {csvEmails.length > 20 && ( + + … and {csvEmails.length - 20} more + + )} + + + + + + + )} + + )} + + {/* ── Tab 1: Invitations ────────────────────────────────────── */} + {tab === 1 && ( + + + All Invitations + + + + + {loading ? ( + + ) : invitations.length === 0 ? ( + No invitations yet. + ) : ( + + + + Email + Status + Sent + Expires + Actions + + + + {invitations.map(inv => ( + + {inv.email} + + + + + {new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} + + + {new Date(inv.expires_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} + + + {inv.status === 'pending' && ( + <> + + handleResend(inv.id, inv.email)}> + + + + + handleCancel(inv.id, inv.email)}> + + + + + )} + + + ))} + +
+ )} +
+ )} + + {/* ── Tab 2: Students ───────────────────────────────────────── */} + {tab === 2 && ( + + + Enrolled Students ({students.length}) + + + + + {loading ? ( + + ) : students.length === 0 ? ( + No enrolled students yet. + ) : ( + + + + Name + Email + Joined + + + + {students.map(s => ( + + + {s.display_name || s.username || 'β€”'} + + + {s.email || 'β€”'} + + + {new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} + + + ))} + +
+ )} +
+ )} +
+ ); +}; + +export default StudentManagerPage; diff --git a/src/pages/timetable/TaughtLessonsPage.tsx b/src/pages/timetable/TaughtLessonsPage.tsx new file mode 100644 index 0000000..e5ce7d7 --- /dev/null +++ b/src/pages/timetable/TaughtLessonsPage.tsx @@ -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; + 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 = { + 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; +} + +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 ( + + + + + {lesson.class_name || lesson.period_code} + + + {lesson.date} Β· {lesson.period_name} + {lesson.start_time && ` Β· ${lesson.start_time}–${lesson.end_time}`} + {lesson.week_cycle && ` Β· Week ${lesson.week_cycle}`} + + + + + + + Status + + + setNotes(e.target.value)} + multiline + minRows={3} + fullWidth + size="small" + placeholder="Lesson notes, objectives, reminders…" + /> + + + + + + + + ); +} + +// ─── Lesson card ────────────────────────────────────────────────────────────── + +interface LessonCardProps { + lesson: Lesson; + onEdit: (l: Lesson) => void; +} + +function LessonCard({ lesson, onEdit }: LessonCardProps) { + return ( + + + + {lesson.class_name || lesson.period_code} + + + onEdit(lesson)} sx={{ ml: 0.5, p: 0.25 }}> + + + + + {lesson.subject && ( + + {lesson.subject}{lesson.year_group ? ` Β· Y${lesson.year_group}` : ''} + + )} + + + {lesson.start_time ? `${lesson.start_time}–${lesson.end_time}` : lesson.period_name} + + {lesson.week_cycle && ( + + )} + + + {lesson.notes && ( + + {lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes} + + )} + + ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +const TaughtLessonsPage: React.FC = () => { + const { accessToken } = useAuth(); + const [weekStart, setWeekStart] = useState(toMonday(new Date())); + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [materializing, setMaterializing] = useState(false); + const [materializeResult, setMaterializeResult] = useState(null); + const [editingLesson, setEditingLesson] = useState(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 ( + + {/* Header */} + + + My Lessons + + {weekLabel} Β· {totalLessons} lesson{totalLessons !== 1 ? 's' : ''} + + + + + + + + + + + + + {materializeResult && ( + setMaterializeResult(null)} + sx={{ mb: 2 }} + > + {materializeResult} + + )} + + {error && {error}} + + {/* Week grid */} + {loading ? ( + + + + ) : ( + + {days.map(day => ( + + {/* Day header */} + + + {day.day_of_week.slice(0, 3)} + + + {formatDate(day.date)} + + + + {/* Lesson cards */} + {day.lessons.length === 0 ? ( + + No lessons + + ) : ( + day.lessons.map(lesson => ( + + )) + )} + + ))} + + )} + + setEditingLesson(null)} + onSave={handleSaveLesson} + /> + + ); +}; + +export default TaughtLessonsPage; diff --git a/src/pages/timetable/index.ts b/src/pages/timetable/index.ts index 2c2820f..5c6d5e0 100644 --- a/src/pages/timetable/index.ts +++ b/src/pages/timetable/index.ts @@ -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'; diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx index b4445ef..e9bc327 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx @@ -170,8 +170,12 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) { const handleClick = () => { if (!isSection) { onSelect(node); - } else if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) { - handleToggle({ stopPropagation: () => {} } as React.MouseEvent); + } else { + // Sections with a real node ID (e.g. the school section) navigate AND expand + if (node.neo4j_node_id) onSelect(node); + if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) { + handleToggle({ stopPropagation: () => {} } as React.MouseEvent); + } } }; @@ -516,7 +520,7 @@ export function CCGraphNavPanel() { }, [accessToken, apiBase]); const handleSelect = useCallback((node: TreeNode) => { - if (!node.is_section) navigateToNeoNode(node); + if (!node.is_section || node.neo4j_node_id) navigateToNeoNode(node); }, [navigateToNeoNode]); const refreshAll = useCallback(() => { diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx index 2f16a4b..97a7d4d 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Stepper, Step, StepLabel, Box, TextField, Typography, IconButton, Select, MenuItem, FormControl, - InputLabel, CircularProgress, Alert, Divider, + InputLabel, CircularProgress, Alert, Divider, Chip, + Tooltip, } from '@mui/material'; import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; import { useAuth } from '../../../../../../contexts/AuthContext'; @@ -13,6 +14,7 @@ interface TermInput { term_number: number; start_date: string; end_date: string; + notes: string; } interface PeriodInput { @@ -20,7 +22,21 @@ interface PeriodInput { name: string; start_time: string; end_time: string; - period_type: 'lesson' | 'break' | 'registration'; + period_type: 'lesson' | 'break' | 'registration' | 'offtimetable'; +} + +interface TermBreakInput { + name: string; + start_date: string; + end_date: string; +} + +interface WeekEntry { + termName: string; + termNumber: number; + weekNumber: number; + startDate: string; + cycle: 'A' | 'B'; } export interface SchoolInfo { @@ -34,9 +50,14 @@ export interface SchoolInfo { } const DEFAULT_TERMS: TermInput[] = [ - { name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19' }, - { name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01' }, - { name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22' }, + { name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19', notes: '' }, + { name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01', notes: '' }, + { name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22', notes: '' }, +]; + +const DEFAULT_TERM_BREAKS: TermBreakInput[] = [ + { name: 'Christmas Break', start_date: '2025-12-22', end_date: '2026-01-02' }, + { name: 'Easter Break', start_date: '2026-04-06', end_date: '2026-04-17' }, ]; const DEFAULT_PERIODS: PeriodInput[] = [ @@ -50,6 +71,49 @@ const DEFAULT_PERIODS: PeriodInput[] = [ { code: 'P5', name: 'Period 5', start_time: '14:05', end_time: '15:05', period_type: 'lesson' }, ]; +// Mirror of Python _academic_weeks: yields {n, monday} for each Mon block overlapping term. +function getAcademicWeeksForTerm( + termStartStr: string, + termEndStr: string, +): { n: number; monday: string }[] { + if (!termStartStr || !termEndStr) return []; + const termStart = new Date(termStartStr + 'T00:00:00'); + const termEnd = new Date(termEndStr + 'T00:00:00'); + const dayOfWeek = termStart.getDay(); // 0=Sun + const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const current = new Date(termStart); + current.setDate(current.getDate() - daysFromMonday); + if (current < termStart) current.setDate(current.getDate() + 7); + const weeks: { n: number; monday: string }[] = []; + let n = 1; + while (current <= termEnd) { + weeks.push({ n, monday: current.toISOString().slice(0, 10) }); + current.setDate(current.getDate() + 7); + n++; + } + return weeks; +} + +function computeWeekEntries(terms: TermInput[]): WeekEntry[] { + const entries: WeekEntry[] = []; + for (const term of terms) { + if (!term.start_date || !term.end_date) continue; + const termWeeks = getAcademicWeeksForTerm(term.start_date, term.end_date); + for (const { n, monday } of termWeeks) { + // default A/B: odd week within term = A, even = B (mirrors backend) + const cycle: 'A' | 'B' = n % 2 === 1 ? 'A' : 'B'; + entries.push({ + termName: term.name, + termNumber: term.term_number, + weekNumber: n, + startDate: monday, + cycle, + }); + } + } + return entries; +} + interface Props { open: boolean; onClose: () => void; @@ -64,21 +128,33 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + // Step 0 const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || ''); const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || ''); const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || ''); + // Step 1 const [yearStart, setYearStart] = useState('2025-09-01'); const [yearEnd, setYearEnd] = useState('2026-07-31'); const [terms, setTerms] = useState(DEFAULT_TERMS); + // Step 2 + const [termBreaks, setTermBreaks] = useState(DEFAULT_TERM_BREAKS); + + // Step 3 (periods) const [periods, setPeriods] = useState(DEFAULT_PERIODS); + // Step 4 (week cycles) β€” computed from terms, user can override + const [weekEntries, setWeekEntries] = useState([]); + + // ── Term helpers ───────────────────────────────────────────────────────── + const addTerm = () => setTerms(prev => [...prev, { name: `Term ${prev.length + 1}`, term_number: prev.length + 1, start_date: '', end_date: '', + notes: '', }]); const removeTerm = (i: number) => setTerms(prev => @@ -88,6 +164,21 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo const updateTerm = (i: number, field: keyof TermInput, value: string) => setTerms(prev => prev.map((t, idx) => idx === i ? { ...t, [field]: value } : t)); + // ── Term break helpers ─────────────────────────────────────────────────── + + const addTermBreak = () => setTermBreaks(prev => [...prev, { + name: `Break ${prev.length + 1}`, + start_date: '', + end_date: '', + }]); + + const removeTermBreak = (i: number) => setTermBreaks(prev => prev.filter((_, idx) => idx !== i)); + + const updateTermBreak = (i: number, field: keyof TermBreakInput, value: string) => + setTermBreaks(prev => prev.map((b, idx) => idx === i ? { ...b, [field]: value } : b)); + + // ── Period helpers ─────────────────────────────────────────────────────── + const addPeriod = () => setPeriods(prev => [...prev, { code: `P${prev.length + 1}`, name: `Period ${prev.length + 1}`, @@ -101,6 +192,28 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo const updatePeriod = (i: number, field: keyof PeriodInput, value: string) => setPeriods(prev => prev.map((p, idx) => idx === i ? { ...p, [field]: value } : p)); + // ── Week cycle helpers ─────────────────────────────────────────────────── + + const toggleWeekCycle = (idx: number) => + setWeekEntries(prev => prev.map((w, i) => + i === idx ? { ...w, cycle: w.cycle === 'A' ? 'B' : 'A' } : w + )); + + const resetWeekCycles = () => + setWeekEntries(computeWeekEntries(terms)); + + // ── Navigation ─────────────────────────────────────────────────────────── + + const goToStep = (next: number) => { + // When entering step 4, compute week entries from current terms + if (next === 4) { + setWeekEntries(computeWeekEntries(terms)); + } + setStep(next); + }; + + // ── Step 0: save school details ────────────────────────────────────────── + const handleSaveSchoolInfo = async () => { if (!accessToken) return; setSaving(true); @@ -124,23 +237,56 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo } }; + // ── Step 4: final save ─────────────────────────────────────────────────── + const handleSaveCalendar = async () => { if (!accessToken) return; setSaving(true); setError(null); try { - const res = await fetch(`${apiBase}/timetable/setup`, { + const payload = { + year_start: yearStart, + year_end: yearEnd, + terms: terms.map(t => ({ + name: t.name, + term_number: t.term_number, + start_date: t.start_date, + end_date: t.end_date, + notes: t.notes || undefined, + })), + periods, + term_breaks: termBreaks.filter(b => b.name && b.start_date && b.end_date), + week_cycles: weekEntries.map(w => ({ + term_number: w.termNumber, + week_number: w.weekNumber, + cycle: w.cycle, + })), + }; + + const setupRes = await fetch(`${apiBase}/timetable/setup`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ year_start: yearStart, year_end: yearEnd, terms, periods }), + body: JSON.stringify(payload), }); - const data = await res.json(); - if (data.status === 'ok') { - onComplete(); - handleClose(); - } else { - setError(data.message || 'Calendar setup failed'); + const setupData = await setupRes.json(); + if (setupData.status !== 'ok') { + setError(setupData.message || 'Calendar setup failed'); + return; } + + // Materialize academic_periods rows + const matRes = await fetch(`${apiBase}/timetable/materialize-periods`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + }); + const matData = await matRes.json(); + if (matData.status !== 'ok') { + // Non-fatal: log and continue + console.warn('Materialize periods returned:', matData); + } + + onComplete(); + handleClose(); } catch (e: any) { setError(e.message); } finally { @@ -157,7 +303,18 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo const addr = schoolInfo.address || {}; const addressStr = [addr.street, addr.town, addr.county, addr.postcode].filter(Boolean).join(', '); - const STEPS = ['School Details', 'Academic Calendar', 'Daily Periods']; + const STEPS = ['School Details', 'Terms', 'Term Breaks', 'Daily Periods', 'Week Cycles']; + + // Group week entries by term for display + const weeksByTerm = useMemo(() => { + const map: Record = {}; + for (const w of weekEntries) { + const key = w.termName; + if (!map[key]) map[key] = []; + map[key].push(w); + } + return map; + }, [weekEntries]); return ( @@ -171,6 +328,7 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo {error && {error}} + {/* ── Step 0: School Details ───────────────────────────────── */} {step === 0 && ( School Information @@ -217,6 +375,7 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo )} + {/* ── Step 1: Terms ────────────────────────────────────────── */} {step === 1 && ( School Year @@ -234,17 +393,65 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo {terms.map((term, i) => ( + + + updateTerm(i, 'name', e.target.value)} + size="small" sx={{ width: 140 }} /> + updateTerm(i, 'start_date', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + updateTerm(i, 'end_date', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + removeTerm(i)}> + + + + updateTerm(i, 'notes', e.target.value)} + size="small" + fullWidth + multiline + minRows={1} + placeholder="Any notes about this term" + sx={{ pl: 0 }} + /> + + ))} + + )} + + {/* ── Step 2: Term Breaks ──────────────────────────────────── */} + {step === 2 && ( + + + Term Breaks + + + + Named holiday periods between terms (Christmas, Easter, half-terms etc.) + + {termBreaks.length === 0 && ( + + No term breaks defined. Click "Add Break" to add one, or skip this step. + + )} + {termBreaks.map((tb, i) => ( - updateTerm(i, 'name', e.target.value)} - size="small" sx={{ width: 140 }} /> - updateTerm(i, 'start_date', e.target.value)} + updateTermBreak(i, 'name', e.target.value)} + size="small" sx={{ width: 180 }} + placeholder="e.g. Christmas Break" /> + updateTermBreak(i, 'start_date', e.target.value)} size="small" InputLabelProps={{ shrink: true }} /> - updateTerm(i, 'end_date', e.target.value)} + updateTermBreak(i, 'end_date', e.target.value)} size="small" InputLabelProps={{ shrink: true }} /> - removeTerm(i)}> + removeTermBreak(i)}> @@ -252,7 +459,8 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo )} - {step === 2 && ( + {/* ── Step 3: Daily Periods ────────────────────────────────── */} + {step === 3 && ( Daily Period Schedule @@ -272,13 +480,14 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo updatePeriod(i, 'end_time', e.target.value)} size="small" InputLabelProps={{ shrink: true }} /> - + Type removePeriod(i)}> @@ -288,6 +497,55 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo ))} )} + + {/* ── Step 4: Week Cycles ──────────────────────────────────── */} + {step === 4 && ( + + + Week A/B Cycles + + + + + + Default: Week 1 = A, Week 2 = B, alternating within each term. Click a chip to toggle. + + {weekEntries.length === 0 && ( + + No weeks found β€” check your term dates. + + )} + {Object.entries(weeksByTerm).map(([termName, weeks]) => ( + + + {termName} + + + {weeks.map((w) => { + const globalIdx = weekEntries.findIndex( + e => e.termNumber === w.termNumber && e.weekNumber === w.weekNumber + ); + return ( + + toggleWeekCycle(globalIdx)} + sx={{ cursor: 'pointer', fontWeight: 600, minWidth: 56 }} + /> + + ); + })} + + + ))} + + )} @@ -302,10 +560,20 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo )} {step === 1 && ( )} {step === 2 && ( + + )} + {step === 3 && ( + + )} + {step === 4 && ( diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx index 0877f58..3bcaa34 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, - Button, Box, TextField, Typography, Table, TableHead, + Button, Box, Typography, Table, TableHead, TableBody, TableRow, TableCell, CircularProgress, Alert, + Autocomplete, TextField, } from '@mui/material'; import { useAuth } from '../../../../../../contexts/AuthContext'; @@ -14,6 +15,14 @@ export interface PeriodTemplate { period_type: string; } +interface ClassOption { + id: string; + name: string; + class_code?: string; + subject?: string; + year_group?: string; +} + const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; function emptyGrid(): Record> { @@ -22,6 +31,13 @@ function emptyGrid(): Record> { return g; } +function classLabel(c: ClassOption): string { + const parts = [c.class_code || c.name]; + if (c.year_group) parts.push(c.year_group); + if (c.subject && c.subject !== c.name) parts.push(c.subject); + return parts.join(' Β· '); +} + interface Props { open: boolean; onClose: () => void; @@ -43,9 +59,11 @@ export function TeacherTimetableWizard({ const [localTimetableId, setLocalTimetableId] = useState(initialTimetableId); const [initializing, setInitializing] = useState(false); const [loadingSlots, setLoadingSlots] = useState(false); + const [loadingClasses, setLoadingClasses] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [grid, setGrid] = useState>>(emptyGrid); + const [classOptions, setClassOptions] = useState([]); const slotsLoadedRef = useRef(false); const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson'); @@ -63,6 +81,23 @@ export function TeacherTimetableWizard({ slotsLoadedRef.current = false; }, [open, initialTimetableId]); + // Load available classes for this institute + useEffect(() => { + if (!open || !accessToken || classOptions.length > 0) return; + setLoadingClasses(true); + fetch(`${apiBase}/database/timetable/classes?active_only=true&limit=200`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then(r => r.json()) + .then(data => { + if (Array.isArray(data.classes)) { + setClassOptions(data.classes); + } + }) + .catch(() => {}) + .finally(() => setLoadingClasses(false)); + }, [open, accessToken, apiBase, classOptions.length]); + // Auto-create TeacherTimetable node if not yet done useEffect(() => { if (!open || localTimetableId || !accessToken || initializing) return; @@ -153,6 +188,7 @@ export function TeacherTimetableWizard({ const handleClose = () => { setError(null); + setClassOptions([]); onClose(); }; @@ -178,16 +214,21 @@ export function TeacherTimetableWizard({ {!initializing && !loadingSlots && localTimetableId && ( - - Enter your class codes for each lesson slot (leave blank if free) + + Select or type a class name for each lesson slot (leave blank if free) + {classOptions.length > 0 && ( + + {classOptions.length} class{classOptions.length !== 1 ? 'es' : ''} available β€” type to filter or enter a custom name + + )} Period {DAYS.map(d => ( - + {d} ))} @@ -208,15 +249,42 @@ export function TeacherTimetableWizard({ {DAYS.map(day => ( - + typeof opt === 'string' ? opt : classLabel(opt) + } value={grid[day]?.[period.code] || ''} - onChange={e => setCell(day, period.code, e.target.value)} - inputProps={{ - style: { textAlign: 'center', fontSize: '0.8rem', padding: '4px 6px' }, + onChange={(_, val) => { + const text = + val === null ? '' + : typeof val === 'string' ? val + : classLabel(val); + setCell(day, period.code, text); }} - sx={{ width: 96 }} + onInputChange={(_, val) => setCell(day, period.code, val)} + renderInput={params => ( + + )} + sx={{ width: 120 }} + disableClearable={false} + loading={loadingClasses} + noOptionsText="Type a class name" + ListboxProps={{ style: { maxHeight: 200 } }} /> ))}