app/src/pages/timetable/StaffManagerPage.tsx
kcar fedbd903ff
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
fix: centralize app API URL fallbacks
2026-05-28 19:26:00 +01:00

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;