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;
|
||||
|
||||
Reference in New Issue
Block a user