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_BASE || import.meta.env.VITE_API_URL || '/api'; 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; } const STATUS_COLORS: Record = { pending: 'warning', accepted: 'success', expired: 'error', cancelled: 'default', }; const StudentManagerPage: React.FC = () => { const { accessToken } = useAuth(); const [tab, setTab] = useState(0); const [students, setStudents] = useState([]); const [invitations, setInvitations] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [successMsg, setSuccessMsg] = useState(null); // Single invite const [inviteEmail, setInviteEmail] = useState(''); const [inviting, setInviting] = useState(false); // CSV import const fileRef = useRef(null); const [csvEmails, setCsvEmails] = useState([]); 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) => { 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 ( Student Manager Invite students individually or via CSV import {error && setError(null)}>{error}} {successMsg && setSuccessMsg(null)}>{successMsg}} setTab(v)} sx={{ mt: 2, mb: 2 }}> {/* ── Tab 0: Invite ─────────────────────────────────────────── */} {tab === 0 && ( Single invite setInviteEmail(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleInvite()} size="small" sx={{ flex: 1 }} placeholder="student@school.edu" disabled={inviting} /> Bulk CSV import Upload a .csv or .txt file with one email per line (or comma/semicolon separated). Duplicate and invalid addresses are filtered automatically. {csvPreview && csvEmails.length > 0 && ( {csvEmails.length} valid email{csvEmails.length !== 1 ? 's' : ''} found {csvEmails.slice(0, 20).map(e => ( {e} ))} {csvEmails.length > 20 && ( … and {csvEmails.length - 20} more )} )} )} {/* ── Tab 1: Invitations ────────────────────────────────────── */} {tab === 1 && ( All Invitations {loading ? ( ) : invitations.length === 0 ? ( No invitations yet. ) : ( Email Status Sent Expires Actions {invitations.map(inv => ( {inv.email} {new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} {new Date(inv.expires_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} {inv.status === 'pending' && ( <> handleResend(inv.id, inv.email)}> handleCancel(inv.id, inv.email)}> )} ))}
)}
)} {/* ── Tab 2: Students ───────────────────────────────────────── */} {tab === 2 && ( Enrolled Students ({students.length}) {loading ? ( ) : students.length === 0 ? ( No enrolled students yet. ) : ( Name Email Joined {students.map(s => ( {s.display_name || s.username || '—'} {s.email || '—'} {new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} ))}
)}
)}
); }; export default StudentManagerPage;