429 lines
19 KiB
TypeScript
429 lines
19 KiB
TypeScript
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_BASE || import.meta.env.VITE_API_URL || '/api';
|
|
|
|
// ─── 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 { accessToken, user, bootstrapData } = useAuth();
|
|
|
|
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);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!accessToken || !classId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
}).then(r => r.json());
|
|
if (clsRes.id) setCls(clsRes);
|
|
else setError(clsRes.detail || 'Class not found');
|
|
const role = bootstrapData?.active_institute?.membership_role || '';
|
|
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [accessToken, classId, bootstrapData]);
|
|
|
|
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();
|
|
};
|
|
|
|
const apiDelete = async (path: string) => {
|
|
await fetch(`${API_BASE}${path}`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
};
|
|
|
|
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;
|
|
|
|
return (
|
|
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
|
{/* Header */}
|
|
<Button
|
|
size="small"
|
|
startIcon={<ArrowBack />}
|
|
onClick={() => navigate('/classes')}
|
|
sx={{ mb: 2 }}
|
|
>
|
|
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;
|