168 lines
7.8 KiB
TypeScript
168 lines
7.8 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Box, Typography, Button, CircularProgress, Alert, Chip,
|
|
Table, TableHead, TableBody, TableRow, TableCell,
|
|
Card, CardContent, Grid, IconButton, Tooltip,
|
|
} from '@mui/material';
|
|
import { Refresh, School, People, EventNote, HourglassEmpty } from '@mui/icons-material';
|
|
import { useNavigate } from 'react-router';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
|
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
|
|
|
interface SchoolEntry {
|
|
id: string;
|
|
name: string;
|
|
urn: string | null;
|
|
website: string | null;
|
|
status: string;
|
|
created_at: string;
|
|
neo4j_uuid_string: string | null;
|
|
staff_count: number;
|
|
student_count: number;
|
|
has_calendar: boolean;
|
|
pending_invitations: number;
|
|
}
|
|
|
|
interface Stats {
|
|
schools: number;
|
|
profiles: number;
|
|
taught_lessons: number;
|
|
pending_invitations: number;
|
|
}
|
|
|
|
function StatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ElementType }) {
|
|
return (
|
|
<Card>
|
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
|
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const PlatformAdminPage: React.FC = () => {
|
|
const { accessToken } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [schools, setSchools] = useState<SchoolEntry[]>([]);
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!accessToken) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [schoolsRes, statsRes] = await Promise.all([
|
|
fetch(`${API_BASE}/admin/schools`, { headers }).then(r => r.json()),
|
|
fetch(`${API_BASE}/admin/stats`, { headers }).then(r => r.json()),
|
|
]);
|
|
if (schoolsRes.status === 'ok') setSchools(schoolsRes.schools || []);
|
|
else setError(schoolsRes.detail || 'Failed to load schools');
|
|
if (statsRes.status === 'ok') setStats(statsRes);
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => { loadData(); }, [loadData]);
|
|
|
|
return (
|
|
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>Platform Admin</Typography>
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Classroom Copilot — system overview</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Tooltip title="Refresh">
|
|
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
|
|
</Tooltip>
|
|
<Button size="small" variant="outlined" onClick={() => navigate('/dashboard')}>← Dashboard</Button>
|
|
</Box>
|
|
</Box>
|
|
|
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
|
|
|
{stats && (
|
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
|
<Grid item xs={6} sm={3}><StatCard label="Schools" value={stats.schools} icon={School} /></Grid>
|
|
<Grid item xs={6} sm={3}><StatCard label="Profiles" value={stats.profiles} icon={People} /></Grid>
|
|
<Grid item xs={6} sm={3}><StatCard label="Taught Lessons" value={stats.taught_lessons} icon={EventNote} /></Grid>
|
|
<Grid item xs={6} sm={3}><StatCard label="Pending Invites" value={stats.pending_invitations} icon={HourglassEmpty} /></Grid>
|
|
</Grid>
|
|
)}
|
|
|
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
|
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
|
) : schools.length === 0 ? (
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No schools registered.</Typography>
|
|
) : (
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>School</TableCell>
|
|
<TableCell>URN</TableCell>
|
|
<TableCell>Staff</TableCell>
|
|
<TableCell>Students</TableCell>
|
|
<TableCell>Calendar</TableCell>
|
|
<TableCell>Invites</TableCell>
|
|
<TableCell>Neo4j</TableCell>
|
|
<TableCell>Registered</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{schools.map(s => (
|
|
<TableRow key={s.id}>
|
|
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>{s.name}</TableCell>
|
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
|
|
{s.urn || '—'}
|
|
</TableCell>
|
|
<TableCell>{s.staff_count}</TableCell>
|
|
<TableCell>{s.student_count}</TableCell>
|
|
<TableCell>
|
|
<Chip
|
|
label={s.has_calendar ? 'Yes' : 'No'}
|
|
size="small"
|
|
color={s.has_calendar ? 'success' : 'default'}
|
|
sx={{ fontSize: '0.65rem', height: 18 }}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
{s.pending_invitations > 0
|
|
? <Chip label={s.pending_invitations} size="small" color="warning" sx={{ fontSize: '0.65rem', height: 18 }} />
|
|
: <Typography variant="caption" sx={{ color: 'text.disabled' }}>—</Typography>}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Chip
|
|
label={s.neo4j_uuid_string ? 'Provisioned' : 'Pending'}
|
|
size="small"
|
|
color={s.neo4j_uuid_string ? 'success' : 'warning'}
|
|
sx={{ fontSize: '0.65rem', height: 18 }}
|
|
/>
|
|
</TableCell>
|
|
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
|
{new Date(s.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default PlatformAdminPage;
|