381 lines
18 KiB
TypeScript
381 lines
18 KiB
TypeScript
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<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;
|