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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,389 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
TextField, Select, MenuItem, FormControl, InputLabel,
|
||||
Divider, IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Today, EditNote,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
date: string;
|
||||
period_code: string;
|
||||
period_name: string;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
class_name: string | null;
|
||||
subject: string | null;
|
||||
year_group: string | null;
|
||||
week_cycle: string;
|
||||
day_of_week: string;
|
||||
status: string;
|
||||
lesson_plan: Record<string, any>;
|
||||
notes: string | null;
|
||||
whiteboard_room_id: string | null;
|
||||
}
|
||||
|
||||
interface DayEntry {
|
||||
date: string;
|
||||
day_of_week: string;
|
||||
is_today: boolean;
|
||||
lessons: Lesson[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toMonday(d: Date): string {
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
const monday = new Date(d);
|
||||
monday.setDate(d.getDate() + diff);
|
||||
return monday.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addWeeks(isoDate: string, n: number): string {
|
||||
const d = new Date(isoDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + n * 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDate(isoDate: string): string {
|
||||
return new Date(isoDate + 'T00:00:00').toLocaleDateString('en-GB', {
|
||||
day: 'numeric', month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
||||
planned: 'default',
|
||||
in_progress: 'primary',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
substituted: 'warning',
|
||||
};
|
||||
|
||||
// ─── Lesson edit dialog ───────────────────────────────────────────────────────
|
||||
|
||||
interface EditDialogProps {
|
||||
lesson: Lesson | null;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, updates: { notes?: string; status?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
|
||||
const [notes, setNotes] = useState('');
|
||||
const [status, setStatus] = useState('planned');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (lesson) {
|
||||
setNotes(lesson.notes || '');
|
||||
setStatus(lesson.status || 'planned');
|
||||
}
|
||||
}, [lesson]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!lesson) return;
|
||||
setSaving(true);
|
||||
await onSave(lesson.id, { notes, status });
|
||||
setSaving(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!lesson) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={!!lesson} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{lesson.class_name || lesson.period_code}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{lesson.date} · {lesson.period_name}
|
||||
{lesson.start_time && ` · ${lesson.start_time}–${lesson.end_time}`}
|
||||
{lesson.week_cycle && ` · Week ${lesson.week_cycle}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select label="Status" value={status} onChange={e => setStatus(e.target.value)}>
|
||||
<MenuItem value="planned">Planned</MenuItem>
|
||||
<MenuItem value="in_progress">In Progress</MenuItem>
|
||||
<MenuItem value="completed">Completed</MenuItem>
|
||||
<MenuItem value="cancelled">Cancelled</MenuItem>
|
||||
<MenuItem value="substituted">Substituted</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Lesson notes, objectives, reminders…"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={saving}>
|
||||
{saving ? <CircularProgress size={18} /> : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lesson card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface LessonCardProps {
|
||||
lesson: Lesson;
|
||||
onEdit: (l: Lesson) => void;
|
||||
}
|
||||
|
||||
function LessonCard({ lesson, onEdit }: LessonCardProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
mb: 0.75,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
|
||||
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{lesson.class_name || lesson.period_code}
|
||||
</Typography>
|
||||
<Tooltip title="Edit lesson">
|
||||
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ ml: 0.5, p: 0.25 }}>
|
||||
<EditNote fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{lesson.subject && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{lesson.start_time ? `${lesson.start_time}–${lesson.end_time}` : lesson.period_name}
|
||||
</Typography>
|
||||
{lesson.week_cycle && (
|
||||
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||||
)}
|
||||
<Chip
|
||||
label={lesson.status}
|
||||
size="small"
|
||||
color={STATUS_COLORS[lesson.status] ?? 'default'}
|
||||
sx={{ height: 16, fontSize: '0.65rem' }}
|
||||
/>
|
||||
</Box>
|
||||
{lesson.notes && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem', fontStyle: 'italic', mt: 0.25 }}>
|
||||
{lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const TaughtLessonsPage: React.FC = () => {
|
||||
const { accessToken } = useAuth();
|
||||
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
|
||||
const [days, setDays] = useState<DayEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [materializing, setMaterializing] = useState(false);
|
||||
const [materializeResult, setMaterializeResult] = useState<string | null>(null);
|
||||
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
|
||||
|
||||
const loadLessons = useCallback(async (ws: string) => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/timetable/lessons?week_start=${ws}&weeks=1`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
setDays(data.days);
|
||||
} else {
|
||||
setError(data.message || 'Failed to load lessons');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { loadLessons(weekStart); }, [weekStart, loadLessons]);
|
||||
|
||||
const goThisWeek = () => setWeekStart(toMonday(new Date()));
|
||||
const prevWeek = () => setWeekStart(ws => addWeeks(ws, -1));
|
||||
const nextWeek = () => setWeekStart(ws => addWeeks(ws, 1));
|
||||
|
||||
const handleMaterialize = async () => {
|
||||
if (!accessToken) return;
|
||||
setMaterializing(true);
|
||||
setMaterializeResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/timetable/materialize`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
setMaterializeResult(`Created ${data.lessons_upserted} lessons, ${data.whiteboard_rooms_created} rooms`);
|
||||
loadLessons(weekStart);
|
||||
} else {
|
||||
setMaterializeResult(`Error: ${data.message}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setMaterializeResult(`Error: ${e.message}`);
|
||||
} finally {
|
||||
setMaterializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLesson = async (id: string, updates: { notes?: string; status?: string }) => {
|
||||
if (!accessToken) return;
|
||||
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
loadLessons(weekStart);
|
||||
};
|
||||
|
||||
const totalLessons = days.reduce((sum, d) => sum + d.lessons.length, 0);
|
||||
|
||||
// Format week label
|
||||
const weekDates = days.filter(d => d.lessons.length > 0 || true).map(d => d.date);
|
||||
const weekLabel = days.length > 0
|
||||
? `${formatDate(days[0].date)} – ${formatDate(days[days.length - 1].date)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>My Lessons</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleMaterialize}
|
||||
disabled={materializing}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{materializing ? <CircularProgress size={14} sx={{ mr: 1 }} /> : null}
|
||||
Generate Lessons
|
||||
</Button>
|
||||
<Tooltip title="Go to this week">
|
||||
<IconButton size="small" onClick={goThisWeek}><Today fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={prevWeek}><ChevronLeft /></IconButton>
|
||||
<IconButton size="small" onClick={nextWeek}><ChevronRight /></IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{materializeResult && (
|
||||
<Alert
|
||||
severity={materializeResult.startsWith('Error') ? 'error' : 'success'}
|
||||
onClose={() => setMaterializeResult(null)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{materializeResult}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{/* Week grid */}
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
|
||||
{days.map(day => (
|
||||
<Box key={day.date}>
|
||||
{/* Day header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
mb: 0.75,
|
||||
borderRadius: 1,
|
||||
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
|
||||
color: day.is_today ? 'primary.contrastText' : 'text.primary',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
|
||||
{day.day_of_week.slice(0, 3)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
|
||||
{formatDate(day.date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Lesson cards */}
|
||||
{day.lessons.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
|
||||
No lessons
|
||||
</Typography>
|
||||
) : (
|
||||
day.lessons.map(lesson => (
|
||||
<LessonCard
|
||||
key={lesson.id}
|
||||
lesson={lesson}
|
||||
onEdit={setEditingLesson}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<LessonEditDialog
|
||||
lesson={editingLesson}
|
||||
onClose={() => setEditingLesson(null)}
|
||||
onSave={handleSaveLesson}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaughtLessonsPage;
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* Timetable Module - Page Exports
|
||||
*
|
||||
* 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';
|
||||
|
||||
Reference in New Issue
Block a user