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