323 lines
15 KiB
TypeScript
323 lines
15 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Box, Typography, Button, CircularProgress, Alert, Chip,
|
|
Table, TableHead, TableBody, TableRow, TableCell,
|
|
Select, MenuItem, FormControl, Tooltip, Divider,
|
|
Accordion, AccordionSummary, AccordionDetails, Grid,
|
|
Card, CardContent, IconButton,
|
|
} from '@mui/material';
|
|
import {
|
|
ExpandMore, People, School, MenuBook, EventNote,
|
|
HourglassEmpty, Refresh, Edit,
|
|
} 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';
|
|
|
|
const DAY_TYPE_OPTIONS = ['Academic', 'Holiday', 'Staff', 'OffTimetable'];
|
|
const DAY_TYPE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning' | 'error'> = {
|
|
Academic: 'primary',
|
|
Holiday: 'warning',
|
|
Staff: 'success',
|
|
OffTimetable: 'default',
|
|
};
|
|
|
|
interface Term {
|
|
id: string;
|
|
term_name: string;
|
|
term_number: number;
|
|
start_date: string;
|
|
end_date: string;
|
|
academic_days: number;
|
|
total_days: number;
|
|
is_current: boolean;
|
|
}
|
|
|
|
interface CalendarDay {
|
|
id: string;
|
|
date: string;
|
|
day_of_week: string;
|
|
day_type: string;
|
|
week_cycle: string;
|
|
week_number: number | null;
|
|
academic_day_number: number | null;
|
|
excluded_period_codes: string[];
|
|
}
|
|
|
|
interface Overview {
|
|
user_role: string;
|
|
counts: {
|
|
staff: number;
|
|
students: number;
|
|
classes: number;
|
|
pending_invitations: number;
|
|
};
|
|
terms: Term[];
|
|
has_calendar: boolean;
|
|
}
|
|
|
|
// ─── Stat card ────────────────────────────────────────────────────────────────
|
|
|
|
function StatCard({ label, value, icon: Icon, onClick }: {
|
|
label: string; value: number | string; icon: React.ElementType; onClick?: () => void;
|
|
}) {
|
|
return (
|
|
<Card
|
|
sx={{ cursor: onClick ? 'pointer' : 'default', '&:hover': onClick ? { bgcolor: 'action.hover' } : {} }}
|
|
onClick={onClick}
|
|
>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// ─── Calendar days table ──────────────────────────────────────────────────────
|
|
|
|
function CalendarDaysTable({ termId, headers, isAdmin }: {
|
|
termId: string; headers: Record<string, string>; isAdmin: boolean;
|
|
}) {
|
|
const [days, setDays] = useState<CalendarDay[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
fetch(`${API_BASE}/school/calendar/days?term_id=${termId}`, { headers })
|
|
.then(r => r.json())
|
|
.then(data => setDays(data.days || []))
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
}, [termId]);
|
|
|
|
const handleTypeChange = async (dayId: string, newType: string) => {
|
|
setSaving(dayId);
|
|
try {
|
|
await fetch(`${API_BASE}/school/calendar/days/${dayId}`, {
|
|
method: 'PATCH',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ day_type: newType }),
|
|
});
|
|
setDays(prev => prev.map(d => d.id === dayId ? { ...d, day_type: newType } : d));
|
|
} catch { }
|
|
setSaving(null);
|
|
};
|
|
|
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}><CircularProgress size={20} /></Box>;
|
|
|
|
return (
|
|
<Box sx={{ overflowX: 'auto' }}>
|
|
<Table size="small" sx={{ minWidth: 500 }}>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Date</TableCell>
|
|
<TableCell>Day</TableCell>
|
|
<TableCell>Week</TableCell>
|
|
<TableCell>Cycle</TableCell>
|
|
<TableCell>Type</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{days.map(day => (
|
|
<TableRow key={day.id} sx={{ opacity: day.day_type !== 'Academic' ? 0.7 : 1 }}>
|
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.78rem' }}>{day.date}</TableCell>
|
|
<TableCell sx={{ fontSize: '0.78rem' }}>{day.day_of_week.slice(0, 3)}</TableCell>
|
|
<TableCell sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{day.week_number ?? '—'}</TableCell>
|
|
<TableCell>
|
|
{day.week_cycle
|
|
? <Chip label={`W${day.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
|
: <Typography variant="caption" sx={{ color: 'text.disabled' }}>—</Typography>}
|
|
</TableCell>
|
|
<TableCell>
|
|
{isAdmin ? (
|
|
<FormControl size="small" sx={{ minWidth: 110 }}>
|
|
<Select
|
|
value={day.day_type}
|
|
onChange={e => handleTypeChange(day.id, e.target.value)}
|
|
disabled={saving === day.id}
|
|
sx={{ fontSize: '0.75rem', height: 24 }}
|
|
>
|
|
{DAY_TYPE_OPTIONS.map(opt => (
|
|
<MenuItem key={opt} value={opt} sx={{ fontSize: '0.78rem' }}>{opt}</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
) : (
|
|
<Chip
|
|
label={day.day_type}
|
|
size="small"
|
|
color={DAY_TYPE_COLORS[day.day_type] ?? 'default'}
|
|
sx={{ fontSize: '0.65rem', height: 18 }}
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
|
|
|
const SchoolSettingsPage: React.FC = () => {
|
|
const { accessToken } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [overview, setOverview] = useState<Overview | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedTerm, setExpandedTerm] = useState<string | false>(false);
|
|
|
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
|
|
const loadOverview = useCallback(async () => {
|
|
if (!accessToken) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/school/overview`, { headers });
|
|
const data = await res.json();
|
|
if (data.status === 'ok') setOverview(data);
|
|
else setError(data.detail || 'Failed to load school overview');
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => { loadOverview(); }, [loadOverview]);
|
|
|
|
const isAdmin = overview?.user_role === 'school_admin' || overview?.user_role === 'department_head';
|
|
|
|
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}><CircularProgress /></Box>;
|
|
|
|
return (
|
|
<Box sx={{ p: 3, maxWidth: 960, mx: 'auto' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>School Settings</Typography>
|
|
{overview && (
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
{overview.user_role}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
<Tooltip title="Refresh">
|
|
<IconButton size="small" onClick={loadOverview}><Refresh fontSize="small" /></IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
|
|
|
{overview && (
|
|
<>
|
|
{/* Stat cards */}
|
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard label="Staff" value={overview.counts.staff} icon={People} onClick={() => navigate('/staff-manager')} />
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard label="Students" value={overview.counts.students} icon={School} onClick={() => navigate('/student-manager')} />
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard label="Classes" value={overview.counts.classes} icon={MenuBook} onClick={() => navigate('/classes')} />
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard label="Pending invites" value={overview.counts.pending_invitations} icon={HourglassEmpty} />
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<Divider sx={{ mb: 3 }} />
|
|
|
|
{/* Calendar */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>Academic Calendar</Typography>
|
|
{!overview.has_calendar && (
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
No calendar configured
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
|
|
{overview.terms.length === 0 ? (
|
|
<Alert severity="info">
|
|
No academic calendar set up yet. Use the School Calendar Wizard in the navigation panel to set one up.
|
|
</Alert>
|
|
) : (
|
|
overview.terms.map(term => (
|
|
<Accordion
|
|
key={term.id}
|
|
expanded={expandedTerm === term.id}
|
|
onChange={(_, open) => setExpandedTerm(open ? term.id : false)}
|
|
sx={{ mb: 0.5 }}
|
|
>
|
|
<AccordionSummary expandIcon={<ExpandMore />}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%', pr: 2 }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 80 }}>
|
|
{term.term_name}
|
|
</Typography>
|
|
{term.is_current && (
|
|
<Chip label="Current" size="small" color="primary" sx={{ fontSize: '0.65rem', height: 18 }} />
|
|
)}
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
{term.start_date} → {term.end_date}
|
|
</Typography>
|
|
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
|
<Chip label={`${term.academic_days} teaching days`} size="small" sx={{ fontSize: '0.65rem', height: 18 }} />
|
|
{term.total_days > term.academic_days && (
|
|
<Chip
|
|
label={`${term.total_days - term.academic_days} non-teaching`}
|
|
size="small"
|
|
color="warning"
|
|
sx={{ fontSize: '0.65rem', height: 18 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</AccordionSummary>
|
|
<AccordionDetails sx={{ p: 0 }}>
|
|
<CalendarDaysTable termId={term.id} headers={headers} isAdmin={isAdmin} />
|
|
{isAdmin && (
|
|
<Typography variant="caption" sx={{ display: 'block', px: 2, py: 1, color: 'text.secondary' }}>
|
|
Changing a day type automatically creates or removes its periods.
|
|
</Typography>
|
|
)}
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
))
|
|
)}
|
|
|
|
<Divider sx={{ my: 3 }} />
|
|
|
|
{/* Quick links */}
|
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Manage</Typography>
|
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
|
<Button variant="outlined" size="small" startIcon={<People />} onClick={() => navigate('/staff-manager')}>
|
|
Staff Manager
|
|
</Button>
|
|
<Button variant="outlined" size="small" startIcon={<School />} onClick={() => navigate('/student-manager')}>
|
|
Student Manager
|
|
</Button>
|
|
<Button variant="outlined" size="small" startIcon={<MenuBook />} onClick={() => navigate('/classes')}>
|
|
Classes
|
|
</Button>
|
|
<Button variant="outlined" size="small" startIcon={<EventNote />} onClick={() => navigate('/my-lessons')}>
|
|
My Lessons
|
|
</Button>
|
|
</Box>
|
|
</>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default SchoolSettingsPage;
|