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:
2026-05-27 02:56:04 +01:00
co-authored by Claude Sonnet 4.6
parent 138dfb1531
commit 6ac5ab7b5c
13 changed files with 2663 additions and 384 deletions
@@ -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(() => {
@@ -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>
@@ -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>
))}