281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Box, Typography, Button, CircularProgress, Alert, Chip,
|
|
TextField, Select, MenuItem, FormControl, InputLabel,
|
|
Table, TableHead, TableBody, TableRow, TableCell,
|
|
IconButton, Tooltip, Divider,
|
|
} from '@mui/material';
|
|
import {
|
|
PersonAdd, Cancel, Send, Refresh,
|
|
} 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 StaffMember {
|
|
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;
|
|
}
|
|
|
|
const ROLE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning'> = {
|
|
school_admin: 'primary',
|
|
department_head: 'warning',
|
|
teacher: 'success',
|
|
};
|
|
|
|
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
|
pending: 'warning',
|
|
accepted: 'success',
|
|
expired: 'error',
|
|
cancelled: 'default',
|
|
};
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
const StaffManagerPage: React.FC = () => {
|
|
const { accessToken } = useAuth();
|
|
const [staff, setStaff] = useState<StaffMember[]>([]);
|
|
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
|
|
|
// Invite form
|
|
const [inviteEmail, setInviteEmail] = useState('');
|
|
const [inviteRole, setInviteRole] = useState('teacher');
|
|
const [inviting, setInviting] = useState(false);
|
|
|
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!accessToken) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [staffRes, invRes] = await Promise.all([
|
|
fetch(`${API_BASE}/users/staff`, { headers }),
|
|
fetch(`${API_BASE}/users/invitations?role=teacher&status=pending`, { headers })
|
|
.then(r => r.json())
|
|
.catch(() => ({ invitations: [] })),
|
|
].map((p, i) => i === 0 ? (p as Promise<Response>).then(r => r.json()) : p));
|
|
if (staffRes.status === 'ok') setStaff(staffRes.staff || []);
|
|
else setError(staffRes.detail || 'Failed to load staff');
|
|
setInvitations(invRes.invitations || []);
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => { loadData(); }, [loadData]);
|
|
|
|
const handleInvite = async () => {
|
|
if (!inviteEmail.trim() || !accessToken) return;
|
|
setInviting(true);
|
|
setError(null);
|
|
setSuccessMsg(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/users/invite`, {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: inviteEmail.trim().toLowerCase(), role: inviteRole }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'ok' || data.status === 'already_pending') {
|
|
setSuccessMsg(
|
|
data.status === 'already_pending'
|
|
? `Invitation already pending for ${inviteEmail}`
|
|
: `Invitation sent to ${inviteEmail}`
|
|
);
|
|
setInviteEmail('');
|
|
loadData();
|
|
} else {
|
|
setError(data.detail || data.message || 'Invite failed');
|
|
}
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setInviting(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = async (id: string, email: string) => {
|
|
if (!accessToken) return;
|
|
try {
|
|
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
|
|
setSuccessMsg(`Cancelled invitation for ${email}`);
|
|
loadData();
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
}
|
|
};
|
|
|
|
const handleResend = async (id: string, email: string) => {
|
|
if (!accessToken) return;
|
|
try {
|
|
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');
|
|
}
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Staff Manager</Typography>
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
Invite teachers, department heads, and school admins
|
|
</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>}
|
|
|
|
{/* Invite form */}
|
|
<Box sx={{ display: 'flex', gap: 1, mt: 3, alignItems: 'flex-end' }}>
|
|
<TextField
|
|
label="Email address"
|
|
value={inviteEmail}
|
|
onChange={e => setInviteEmail(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleInvite()}
|
|
size="small"
|
|
sx={{ flex: 1 }}
|
|
placeholder="teacher@school.edu"
|
|
disabled={inviting}
|
|
/>
|
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
|
<InputLabel>Role</InputLabel>
|
|
<Select label="Role" value={inviteRole} onChange={e => setInviteRole(e.target.value)} disabled={inviting}>
|
|
<MenuItem value="teacher">Teacher</MenuItem>
|
|
<MenuItem value="department_head">Department Head</MenuItem>
|
|
<MenuItem value="school_admin">School Admin</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
|
|
onClick={handleInvite}
|
|
disabled={inviting || !inviteEmail.trim()}
|
|
sx={{ whiteSpace: 'nowrap' }}
|
|
>
|
|
Send Invite
|
|
</Button>
|
|
<Tooltip title="Refresh">
|
|
<IconButton size="small" onClick={loadData} disabled={loading}>
|
|
<Refresh fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{/* Pending invitations */}
|
|
{invitations.length > 0 && (
|
|
<Box sx={{ mt: 3 }}>
|
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Pending Invitations ({invitations.length})</Typography>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Email</TableCell>
|
|
<TableCell>Role</TableCell>
|
|
<TableCell>Status</TableCell>
|
|
<TableCell>Sent</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.role} size="small" color={ROLE_COLORS[inv.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
|
</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 align="right">
|
|
<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>
|
|
)}
|
|
|
|
<Divider sx={{ my: 3 }} />
|
|
|
|
{/* Active staff */}
|
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
Active Staff ({loading ? '…' : staff.length})
|
|
</Typography>
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
|
) : staff.length === 0 ? (
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No staff members yet.</Typography>
|
|
) : (
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Name</TableCell>
|
|
<TableCell>Email</TableCell>
|
|
<TableCell>Role</TableCell>
|
|
<TableCell>Joined</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{staff.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>
|
|
<Chip label={s.role} size="small" color={ROLE_COLORS[s.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
|
</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>
|
|
);
|
|
};
|
|
|
|
export default StaffManagerPage;
|