feat(phase-b): student enrollment UI, teacher/student management pages, lesson views

- ClassDetailPage: full rewrite with MUI tabs (students, enrollment requests, teachers),
  add/remove students, approve/reject enrollment requests, AddStudentDialog
- StudentLessonsPage: new — student's weekly lesson view with week navigation
- TaughtLessonsPage: teacher's taught lesson week view
- SchoolSettingsPage, StaffManagerPage, StudentManagerPage: school admin management pages
- PlatformAdminPage: platform admin reset/seed controls
- Header: expanded nav menu (student lessons, school management, platform admin items)
- AppRoutes: routes for all new pages
- SchoolCalendarWizard, TeacherTimetableWizard: week_cycle support and improvements
- CCGraphNavPanel: updated navigation integration
- index.ts: export all new timetable pages

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-27 02:56:04 +01:00
co-authored by Claude Sonnet 4.6
parent 138dfb1531
commit 6ac5ab7b5c
13 changed files with 2663 additions and 384 deletions
+380
View File
@@ -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;