app/src/pages/auth/PlatformAdminPage.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

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;