feat(phase-b): student enrollment UI, teacher/student management pages, lesson views
- ClassDetailPage: full rewrite with MUI tabs (students, enrollment requests, teachers), add/remove students, approve/reject enrollment requests, AddStudentDialog - StudentLessonsPage: new — student's weekly lesson view with week navigation - TaughtLessonsPage: teacher's taught lesson week view - SchoolSettingsPage, StaffManagerPage, StudentManagerPage: school admin management pages - PlatformAdminPage: platform admin reset/seed controls - Header: expanded nav menu (student lessons, school management, platform admin items) - AppRoutes: routes for all new pages - SchoolCalendarWizard, TeacherTimetableWizard: week_cycle support and improvements - CCGraphNavPanel: updated navigation integration - index.ts: export all new timetable pages Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -170,8 +170,12 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
|
||||
const handleClick = () => {
|
||||
if (!isSection) {
|
||||
onSelect(node);
|
||||
} else if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) {
|
||||
handleToggle({ stopPropagation: () => {} } as React.MouseEvent);
|
||||
} else {
|
||||
// Sections with a real node ID (e.g. the school section) navigate AND expand
|
||||
if (node.neo4j_node_id) onSelect(node);
|
||||
if (canExpand || (isCalendarSection && ctx.calendarMode === 'academic')) {
|
||||
handleToggle({ stopPropagation: () => {} } as React.MouseEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -516,7 +520,7 @@ export function CCGraphNavPanel() {
|
||||
}, [accessToken, apiBase]);
|
||||
|
||||
const handleSelect = useCallback((node: TreeNode) => {
|
||||
if (!node.is_section) navigateToNeoNode(node);
|
||||
if (!node.is_section || node.neo4j_node_id) navigateToNeoNode(node);
|
||||
}, [navigateToNeoNode]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
|
||||
+294
-26
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Button, Stepper, Step, StepLabel, Box, TextField,
|
||||
Typography, IconButton, Select, MenuItem, FormControl,
|
||||
InputLabel, CircularProgress, Alert, Divider,
|
||||
InputLabel, CircularProgress, Alert, Divider, Chip,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||
@@ -13,6 +14,7 @@ interface TermInput {
|
||||
term_number: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface PeriodInput {
|
||||
@@ -20,7 +22,21 @@ interface PeriodInput {
|
||||
name: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
period_type: 'lesson' | 'break' | 'registration';
|
||||
period_type: 'lesson' | 'break' | 'registration' | 'offtimetable';
|
||||
}
|
||||
|
||||
interface TermBreakInput {
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
interface WeekEntry {
|
||||
termName: string;
|
||||
termNumber: number;
|
||||
weekNumber: number;
|
||||
startDate: string;
|
||||
cycle: 'A' | 'B';
|
||||
}
|
||||
|
||||
export interface SchoolInfo {
|
||||
@@ -34,9 +50,14 @@ export interface SchoolInfo {
|
||||
}
|
||||
|
||||
const DEFAULT_TERMS: TermInput[] = [
|
||||
{ name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19' },
|
||||
{ name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01' },
|
||||
{ name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22' },
|
||||
{ name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19', notes: '' },
|
||||
{ name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01', notes: '' },
|
||||
{ name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22', notes: '' },
|
||||
];
|
||||
|
||||
const DEFAULT_TERM_BREAKS: TermBreakInput[] = [
|
||||
{ name: 'Christmas Break', start_date: '2025-12-22', end_date: '2026-01-02' },
|
||||
{ name: 'Easter Break', start_date: '2026-04-06', end_date: '2026-04-17' },
|
||||
];
|
||||
|
||||
const DEFAULT_PERIODS: PeriodInput[] = [
|
||||
@@ -50,6 +71,49 @@ const DEFAULT_PERIODS: PeriodInput[] = [
|
||||
{ code: 'P5', name: 'Period 5', start_time: '14:05', end_time: '15:05', period_type: 'lesson' },
|
||||
];
|
||||
|
||||
// Mirror of Python _academic_weeks: yields {n, monday} for each Mon block overlapping term.
|
||||
function getAcademicWeeksForTerm(
|
||||
termStartStr: string,
|
||||
termEndStr: string,
|
||||
): { n: number; monday: string }[] {
|
||||
if (!termStartStr || !termEndStr) return [];
|
||||
const termStart = new Date(termStartStr + 'T00:00:00');
|
||||
const termEnd = new Date(termEndStr + 'T00:00:00');
|
||||
const dayOfWeek = termStart.getDay(); // 0=Sun
|
||||
const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const current = new Date(termStart);
|
||||
current.setDate(current.getDate() - daysFromMonday);
|
||||
if (current < termStart) current.setDate(current.getDate() + 7);
|
||||
const weeks: { n: number; monday: string }[] = [];
|
||||
let n = 1;
|
||||
while (current <= termEnd) {
|
||||
weeks.push({ n, monday: current.toISOString().slice(0, 10) });
|
||||
current.setDate(current.getDate() + 7);
|
||||
n++;
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
function computeWeekEntries(terms: TermInput[]): WeekEntry[] {
|
||||
const entries: WeekEntry[] = [];
|
||||
for (const term of terms) {
|
||||
if (!term.start_date || !term.end_date) continue;
|
||||
const termWeeks = getAcademicWeeksForTerm(term.start_date, term.end_date);
|
||||
for (const { n, monday } of termWeeks) {
|
||||
// default A/B: odd week within term = A, even = B (mirrors backend)
|
||||
const cycle: 'A' | 'B' = n % 2 === 1 ? 'A' : 'B';
|
||||
entries.push({
|
||||
termName: term.name,
|
||||
termNumber: term.term_number,
|
||||
weekNumber: n,
|
||||
startDate: monday,
|
||||
cycle,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -64,21 +128,33 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Step 0
|
||||
const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || '');
|
||||
const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || '');
|
||||
const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || '');
|
||||
|
||||
// Step 1
|
||||
const [yearStart, setYearStart] = useState('2025-09-01');
|
||||
const [yearEnd, setYearEnd] = useState('2026-07-31');
|
||||
const [terms, setTerms] = useState<TermInput[]>(DEFAULT_TERMS);
|
||||
|
||||
// Step 2
|
||||
const [termBreaks, setTermBreaks] = useState<TermBreakInput[]>(DEFAULT_TERM_BREAKS);
|
||||
|
||||
// Step 3 (periods)
|
||||
const [periods, setPeriods] = useState<PeriodInput[]>(DEFAULT_PERIODS);
|
||||
|
||||
// Step 4 (week cycles) — computed from terms, user can override
|
||||
const [weekEntries, setWeekEntries] = useState<WeekEntry[]>([]);
|
||||
|
||||
// ── Term helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const addTerm = () => setTerms(prev => [...prev, {
|
||||
name: `Term ${prev.length + 1}`,
|
||||
term_number: prev.length + 1,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
notes: '',
|
||||
}]);
|
||||
|
||||
const removeTerm = (i: number) => setTerms(prev =>
|
||||
@@ -88,6 +164,21 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
const updateTerm = (i: number, field: keyof TermInput, value: string) =>
|
||||
setTerms(prev => prev.map((t, idx) => idx === i ? { ...t, [field]: value } : t));
|
||||
|
||||
// ── Term break helpers ───────────────────────────────────────────────────
|
||||
|
||||
const addTermBreak = () => setTermBreaks(prev => [...prev, {
|
||||
name: `Break ${prev.length + 1}`,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
}]);
|
||||
|
||||
const removeTermBreak = (i: number) => setTermBreaks(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
const updateTermBreak = (i: number, field: keyof TermBreakInput, value: string) =>
|
||||
setTermBreaks(prev => prev.map((b, idx) => idx === i ? { ...b, [field]: value } : b));
|
||||
|
||||
// ── Period helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const addPeriod = () => setPeriods(prev => [...prev, {
|
||||
code: `P${prev.length + 1}`,
|
||||
name: `Period ${prev.length + 1}`,
|
||||
@@ -101,6 +192,28 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
const updatePeriod = (i: number, field: keyof PeriodInput, value: string) =>
|
||||
setPeriods(prev => prev.map((p, idx) => idx === i ? { ...p, [field]: value } : p));
|
||||
|
||||
// ── Week cycle helpers ───────────────────────────────────────────────────
|
||||
|
||||
const toggleWeekCycle = (idx: number) =>
|
||||
setWeekEntries(prev => prev.map((w, i) =>
|
||||
i === idx ? { ...w, cycle: w.cycle === 'A' ? 'B' : 'A' } : w
|
||||
));
|
||||
|
||||
const resetWeekCycles = () =>
|
||||
setWeekEntries(computeWeekEntries(terms));
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────
|
||||
|
||||
const goToStep = (next: number) => {
|
||||
// When entering step 4, compute week entries from current terms
|
||||
if (next === 4) {
|
||||
setWeekEntries(computeWeekEntries(terms));
|
||||
}
|
||||
setStep(next);
|
||||
};
|
||||
|
||||
// ── Step 0: save school details ──────────────────────────────────────────
|
||||
|
||||
const handleSaveSchoolInfo = async () => {
|
||||
if (!accessToken) return;
|
||||
setSaving(true);
|
||||
@@ -124,23 +237,56 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 4: final save ───────────────────────────────────────────────────
|
||||
|
||||
const handleSaveCalendar = async () => {
|
||||
if (!accessToken) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/timetable/setup`, {
|
||||
const payload = {
|
||||
year_start: yearStart,
|
||||
year_end: yearEnd,
|
||||
terms: terms.map(t => ({
|
||||
name: t.name,
|
||||
term_number: t.term_number,
|
||||
start_date: t.start_date,
|
||||
end_date: t.end_date,
|
||||
notes: t.notes || undefined,
|
||||
})),
|
||||
periods,
|
||||
term_breaks: termBreaks.filter(b => b.name && b.start_date && b.end_date),
|
||||
week_cycles: weekEntries.map(w => ({
|
||||
term_number: w.termNumber,
|
||||
week_number: w.weekNumber,
|
||||
cycle: w.cycle,
|
||||
})),
|
||||
};
|
||||
|
||||
const setupRes = await fetch(`${apiBase}/timetable/setup`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ year_start: yearStart, year_end: yearEnd, terms, periods }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
onComplete();
|
||||
handleClose();
|
||||
} else {
|
||||
setError(data.message || 'Calendar setup failed');
|
||||
const setupData = await setupRes.json();
|
||||
if (setupData.status !== 'ok') {
|
||||
setError(setupData.message || 'Calendar setup failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Materialize academic_periods rows
|
||||
const matRes = await fetch(`${apiBase}/timetable/materialize-periods`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const matData = await matRes.json();
|
||||
if (matData.status !== 'ok') {
|
||||
// Non-fatal: log and continue
|
||||
console.warn('Materialize periods returned:', matData);
|
||||
}
|
||||
|
||||
onComplete();
|
||||
handleClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -157,7 +303,18 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
const addr = schoolInfo.address || {};
|
||||
const addressStr = [addr.street, addr.town, addr.county, addr.postcode].filter(Boolean).join(', ');
|
||||
|
||||
const STEPS = ['School Details', 'Academic Calendar', 'Daily Periods'];
|
||||
const STEPS = ['School Details', 'Terms', 'Term Breaks', 'Daily Periods', 'Week Cycles'];
|
||||
|
||||
// Group week entries by term for display
|
||||
const weeksByTerm = useMemo(() => {
|
||||
const map: Record<string, WeekEntry[]> = {};
|
||||
for (const w of weekEntries) {
|
||||
const key = w.termName;
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(w);
|
||||
}
|
||||
return map;
|
||||
}, [weekEntries]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
@@ -171,6 +328,7 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
<DialogContent>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{/* ── Step 0: School Details ───────────────────────────────── */}
|
||||
{step === 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Information</Typography>
|
||||
@@ -217,6 +375,7 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Terms ────────────────────────────────────────── */}
|
||||
{step === 1 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Year</Typography>
|
||||
@@ -234,17 +393,65 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addTerm}>Add Term</Button>
|
||||
</Box>
|
||||
{terms.map((term, i) => (
|
||||
<Box key={i} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 0.75, alignItems: 'center' }}>
|
||||
<TextField label="Term Name" value={term.name}
|
||||
onChange={e => updateTerm(i, 'name', e.target.value)}
|
||||
size="small" sx={{ width: 140 }} />
|
||||
<TextField label="Start Date" type="date" value={term.start_date}
|
||||
onChange={e => updateTerm(i, 'start_date', e.target.value)}
|
||||
size="small" InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="End Date" type="date" value={term.end_date}
|
||||
onChange={e => updateTerm(i, 'end_date', e.target.value)}
|
||||
size="small" InputLabelProps={{ shrink: true }} />
|
||||
<IconButton size="small" onClick={() => removeTerm(i)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Notes (optional)"
|
||||
value={term.notes}
|
||||
onChange={e => updateTerm(i, 'notes', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={1}
|
||||
placeholder="Any notes about this term"
|
||||
sx={{ pl: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Term Breaks ──────────────────────────────────── */}
|
||||
{step === 2 && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2">Term Breaks</Typography>
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addTermBreak}>Add Break</Button>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 2 }}>
|
||||
Named holiday periods between terms (Christmas, Easter, half-terms etc.)
|
||||
</Typography>
|
||||
{termBreaks.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
No term breaks defined. Click "Add Break" to add one, or skip this step.
|
||||
</Typography>
|
||||
)}
|
||||
{termBreaks.map((tb, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||
<TextField label="Term Name" value={term.name}
|
||||
onChange={e => updateTerm(i, 'name', e.target.value)}
|
||||
size="small" sx={{ width: 140 }} />
|
||||
<TextField label="Start Date" type="date" value={term.start_date}
|
||||
onChange={e => updateTerm(i, 'start_date', e.target.value)}
|
||||
<TextField label="Break Name" value={tb.name}
|
||||
onChange={e => updateTermBreak(i, 'name', e.target.value)}
|
||||
size="small" sx={{ width: 180 }}
|
||||
placeholder="e.g. Christmas Break" />
|
||||
<TextField label="Start Date" type="date" value={tb.start_date}
|
||||
onChange={e => updateTermBreak(i, 'start_date', e.target.value)}
|
||||
size="small" InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="End Date" type="date" value={term.end_date}
|
||||
onChange={e => updateTerm(i, 'end_date', e.target.value)}
|
||||
<TextField label="End Date" type="date" value={tb.end_date}
|
||||
onChange={e => updateTermBreak(i, 'end_date', e.target.value)}
|
||||
size="small" InputLabelProps={{ shrink: true }} />
|
||||
<IconButton size="small" onClick={() => removeTerm(i)}>
|
||||
<IconButton size="small" onClick={() => removeTermBreak(i)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -252,7 +459,8 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
{/* ── Step 3: Daily Periods ────────────────────────────────── */}
|
||||
{step === 3 && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography variant="subtitle2">Daily Period Schedule</Typography>
|
||||
@@ -272,13 +480,14 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
<TextField label="End" type="time" value={p.end_time}
|
||||
onChange={e => updatePeriod(i, 'end_time', e.target.value)}
|
||||
size="small" InputLabelProps={{ shrink: true }} />
|
||||
<FormControl size="small" sx={{ width: 130 }}>
|
||||
<FormControl size="small" sx={{ width: 150 }}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select label="Type" value={p.period_type}
|
||||
onChange={e => updatePeriod(i, 'period_type', e.target.value)}>
|
||||
<MenuItem value="lesson">Lesson</MenuItem>
|
||||
<MenuItem value="break">Break</MenuItem>
|
||||
<MenuItem value="registration">Registration</MenuItem>
|
||||
<MenuItem value="offtimetable">Off-timetable</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton size="small" onClick={() => removePeriod(i)}>
|
||||
@@ -288,6 +497,55 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Week Cycles ──────────────────────────────────── */}
|
||||
{step === 4 && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2">Week A/B Cycles</Typography>
|
||||
<Tooltip title="Reset to alternating A/B (default)">
|
||||
<Button size="small" onClick={resetWeekCycles}>Reset Defaults</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 2 }}>
|
||||
Default: Week 1 = A, Week 2 = B, alternating within each term. Click a chip to toggle.
|
||||
</Typography>
|
||||
{weekEntries.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
No weeks found — check your term dates.
|
||||
</Typography>
|
||||
)}
|
||||
{Object.entries(weeksByTerm).map(([termName, weeks]) => (
|
||||
<Box key={termName} sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mb: 0.75, display: 'block' }}>
|
||||
{termName}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{weeks.map((w) => {
|
||||
const globalIdx = weekEntries.findIndex(
|
||||
e => e.termNumber === w.termNumber && e.weekNumber === w.weekNumber
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${w.termNumber}-${w.weekNumber}`}
|
||||
title={`w/c ${w.startDate}`}
|
||||
>
|
||||
<Chip
|
||||
label={`W${w.weekNumber} ${w.cycle}`}
|
||||
size="small"
|
||||
color={w.cycle === 'A' ? 'primary' : 'secondary'}
|
||||
variant="outlined"
|
||||
onClick={() => toggleWeekCycle(globalIdx)}
|
||||
sx={{ cursor: 'pointer', fontWeight: 600, minWidth: 56 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
@@ -302,10 +560,20 @@ export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoo
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button onClick={() => setStep(2)} variant="outlined" disabled={saving}>
|
||||
Next: Daily Periods
|
||||
Next: Term Breaks
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button onClick={() => setStep(3)} variant="outlined" disabled={saving}>
|
||||
Next: Daily Periods
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button onClick={() => goToStep(4)} variant="outlined" disabled={saving}>
|
||||
Next: Week Cycles
|
||||
</Button>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Button onClick={handleSaveCalendar} variant="contained" disabled={saving}>
|
||||
{saving ? <CircularProgress size={18} /> : 'Save School Calendar'}
|
||||
</Button>
|
||||
|
||||
+78
-10
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Button, Box, TextField, Typography, Table, TableHead,
|
||||
Button, Box, Typography, Table, TableHead,
|
||||
TableBody, TableRow, TableCell, CircularProgress, Alert,
|
||||
Autocomplete, TextField,
|
||||
} from '@mui/material';
|
||||
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||
|
||||
@@ -14,6 +15,14 @@ export interface PeriodTemplate {
|
||||
period_type: string;
|
||||
}
|
||||
|
||||
interface ClassOption {
|
||||
id: string;
|
||||
name: string;
|
||||
class_code?: string;
|
||||
subject?: string;
|
||||
year_group?: string;
|
||||
}
|
||||
|
||||
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
|
||||
|
||||
function emptyGrid(): Record<string, Record<string, string>> {
|
||||
@@ -22,6 +31,13 @@ function emptyGrid(): Record<string, Record<string, string>> {
|
||||
return g;
|
||||
}
|
||||
|
||||
function classLabel(c: ClassOption): string {
|
||||
const parts = [c.class_code || c.name];
|
||||
if (c.year_group) parts.push(c.year_group);
|
||||
if (c.subject && c.subject !== c.name) parts.push(c.subject);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -43,9 +59,11 @@ export function TeacherTimetableWizard({
|
||||
const [localTimetableId, setLocalTimetableId] = useState<string | null>(initialTimetableId);
|
||||
const [initializing, setInitializing] = useState(false);
|
||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||
const [loadingClasses, setLoadingClasses] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [grid, setGrid] = useState<Record<string, Record<string, string>>>(emptyGrid);
|
||||
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
|
||||
const slotsLoadedRef = useRef(false);
|
||||
|
||||
const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson');
|
||||
@@ -63,6 +81,23 @@ export function TeacherTimetableWizard({
|
||||
slotsLoadedRef.current = false;
|
||||
}, [open, initialTimetableId]);
|
||||
|
||||
// Load available classes for this institute
|
||||
useEffect(() => {
|
||||
if (!open || !accessToken || classOptions.length > 0) return;
|
||||
setLoadingClasses(true);
|
||||
fetch(`${apiBase}/database/timetable/classes?active_only=true&limit=200`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (Array.isArray(data.classes)) {
|
||||
setClassOptions(data.classes);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingClasses(false));
|
||||
}, [open, accessToken, apiBase, classOptions.length]);
|
||||
|
||||
// Auto-create TeacherTimetable node if not yet done
|
||||
useEffect(() => {
|
||||
if (!open || localTimetableId || !accessToken || initializing) return;
|
||||
@@ -153,6 +188,7 @@ export function TeacherTimetableWizard({
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null);
|
||||
setClassOptions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -178,16 +214,21 @@ export function TeacherTimetableWizard({
|
||||
|
||||
{!initializing && !loadingSlots && localTimetableId && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
|
||||
Enter your class codes for each lesson slot (leave blank if free)
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Select or type a class name for each lesson slot (leave blank if free)
|
||||
</Typography>
|
||||
{classOptions.length > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 1.5 }}>
|
||||
{classOptions.length} class{classOptions.length !== 1 ? 'es' : ''} available — type to filter or enter a custom name
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600, minWidth: 100 }}>Period</TableCell>
|
||||
{DAYS.map(d => (
|
||||
<TableCell key={d} align="center" sx={{ fontWeight: 600, minWidth: 110 }}>
|
||||
<TableCell key={d} align="center" sx={{ fontWeight: 600, minWidth: 130 }}>
|
||||
{d}
|
||||
</TableCell>
|
||||
))}
|
||||
@@ -208,15 +249,42 @@ export function TeacherTimetableWizard({
|
||||
</TableCell>
|
||||
{DAYS.map(day => (
|
||||
<TableCell key={day} align="center" sx={{ p: 0.5 }}>
|
||||
<TextField
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
size="small"
|
||||
placeholder="—"
|
||||
options={classOptions}
|
||||
getOptionLabel={opt =>
|
||||
typeof opt === 'string' ? opt : classLabel(opt)
|
||||
}
|
||||
value={grid[day]?.[period.code] || ''}
|
||||
onChange={e => setCell(day, period.code, e.target.value)}
|
||||
inputProps={{
|
||||
style: { textAlign: 'center', fontSize: '0.8rem', padding: '4px 6px' },
|
||||
onChange={(_, val) => {
|
||||
const text =
|
||||
val === null ? ''
|
||||
: typeof val === 'string' ? val
|
||||
: classLabel(val);
|
||||
setCell(day, period.code, text);
|
||||
}}
|
||||
sx={{ width: 96 }}
|
||||
onInputChange={(_, val) => setCell(day, period.code, val)}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder="—"
|
||||
inputProps={{
|
||||
...params.inputProps,
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
fontSize: '0.78rem',
|
||||
padding: '3px 6px',
|
||||
},
|
||||
}}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
sx={{ width: 120 }}
|
||||
disableClearable={false}
|
||||
loading={loadingClasses}
|
||||
noOptionsText="Type a class name"
|
||||
ListboxProps={{ style: { maxHeight: 200 } }}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user