app/src/pages/timetable/SchoolSettingsPage.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

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;