kcar 98a4212e46 feat(nav): timetable view toggle (By Class / By Term), fix cc-section-node crash
- Fix: skip canvas navigation for Section-type nodes (was crashing on cc-section-node)
- Add By Class / By Term toggle to My Timetable section (mirrors calendar Generic/Academic)
- By Class: pre-loaded SubjectClass children shown immediately on expand
- By Term: lazy-loads AcademicTerms -> Weeks -> TaughtLessons when switched to term view
- displayChildren respects timetableView context for the timetable section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 12:12:59 +01:00

732 lines
32 KiB
TypeScript

import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
import {
Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
ToggleButtonGroup, ToggleButton,
} from '@mui/material';
import {
ExpandMore, ChevronRight as ChevronRightIcon,
Home as HomeIcon,
CalendarToday, DateRange, Event,
Schedule as TimetableIcon,
Class as ClassIcon,
MenuBook as CurriculumIcon,
Book as JournalIcon,
EventNote as PlannerIcon,
Business as SchoolIcon,
LinkOff as UnlinkedIcon,
HourglassEmpty as PendingIcon,
School as AcademicIcon,
GridOn as GridIcon,
Settings as SetupIcon,
Edit as EditIcon,
Launch as LaunchIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useNavigationStore } from '../../../../../../stores/navigationStore';
import { useAuth } from '../../../../../../contexts/AuthContext';
import { NeoGraphNode } from '../../../../../../types/navigation';
import { logger } from '../../../../../../debugConfig';
import { SchoolCalendarWizard, SchoolInfo } from './SchoolCalendarWizard';
import { TeacherTimetableWizard, PeriodTemplate } from './TeacherTimetableWizard';
import { SchoolOnboardingWizard } from './SchoolOnboardingWizard';
type NodeStatus = 'populated' | 'empty' | 'no_school' | 'not_initialized';
type CalendarMode = 'generic' | 'academic';
interface TreeNode extends NeoGraphNode {
has_children?: boolean;
children?: TreeNode[];
is_section?: boolean;
section_id?: string;
status?: NodeStatus;
neo4j_props?: Record<string, string>;
}
interface SchoolStatus {
status: string;
user_role?: string;
school_id?: string;
school_has_calendar?: boolean;
teacher_has_timetable?: boolean;
timetable_id?: string | null;
periods_template?: PeriodTemplate[] | null;
school_info?: SchoolInfo;
}
const NODE_ICONS: Record<string, React.ElementType> = {
User: HomeIcon,
CalendarYear: CalendarToday,
CalendarMonth: DateRange,
CalendarWeek: DateRange,
CalendarDay: Event,
AcademicYear: AcademicIcon,
AcademicTerm: AcademicIcon,
AcademicWeek: DateRange,
TeacherTimetable: TimetableIcon,
SubjectClass: ClassIcon,
TimetableLesson: TimetableIcon,
TimetableSlot: GridIcon,
Journal: JournalIcon,
Planner: PlannerIcon,
School: SchoolIcon,
Department: SchoolIcon,
Section: HomeIcon,
};
const SECTION_ICONS: Record<string, React.ElementType> = {
calendar: CalendarToday,
timetable: TimetableIcon,
classes: ClassIcon,
curriculum: CurriculumIcon,
journal: JournalIcon,
planner: PlannerIcon,
school: SchoolIcon,
};
const STATUS_MESSAGES: Record<NodeStatus, string> = {
populated: '',
empty: 'Not set up yet',
no_school: 'Join a school to unlock',
not_initialized: 'Setting up...',
};
// ─── Panel context ─────────────────────────────────────────────────────────────
interface NavPanelContextValue {
calendarMode: CalendarMode;
setCalendarMode: (m: CalendarMode) => void;
academicCalendarStatus: 'idle' | 'loading' | 'populated' | 'empty' | 'no_school' | 'error';
academicTerms: TreeNode[];
schoolStatus: SchoolStatus | null;
onSetupSchoolCalendar: () => void;
onSetupTimetable: () => void;
onOnboardSchool: () => void;
activeNodeId?: string;
timetableView: 'class' | 'term';
setTimetableView: (v: 'class' | 'term') => void;
timetableTermNodes: TreeNode[];
timetableTermStatus: 'idle' | 'loading' | 'populated' | 'empty';
}
const NavPanelContext = createContext<NavPanelContextValue>({
calendarMode: 'generic',
setCalendarMode: () => {},
academicCalendarStatus: 'idle',
academicTerms: [],
schoolStatus: null,
onSetupSchoolCalendar: () => {},
onSetupTimetable: () => {},
onOnboardSchool: () => {},
timetableView: 'class',
setTimetableView: () => {},
timetableTermNodes: [],
timetableTermStatus: 'idle',
});
// ─── TreeItem ─────────────────────────────────────────────────────────────────
interface TreeItemProps {
node: TreeNode;
depth: number;
onSelect: (node: TreeNode) => void;
onExpand: (node: TreeNode) => Promise<TreeNode[]>;
}
function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
const ctx = useContext(NavPanelContext);
const navigate = useNavigate();
const [expanded, setExpanded] = useState(node.is_section && node.status === 'populated');
const [children, setChildren] = useState<TreeNode[]>(node.children || []);
const [loading, setLoading] = useState(false);
const isSection = !!node.is_section;
const isCalendarSection = isSection && node.section_id === 'calendar';
const isTimetableSection = isSection && node.section_id === 'timetable';
const isSchoolSection = isSection && node.section_id === 'school';
const isClassesSection = isSection && node.section_id === 'classes';
const SectionIcon = node.section_id ? SECTION_ICONS[node.section_id] : null;
const Icon = SectionIcon || NODE_ICONS[node.node_type] || HomeIcon;
const canExpand = node.has_children !== false
&& node.node_type !== 'CalendarDay'
&& node.status !== 'empty'
&& node.status !== 'no_school'
&& node.status !== 'not_initialized';
const isActive = !isSection && node.neo4j_node_id === ctx.activeNodeId;
const isEmpty = node.status === 'empty' || node.status === 'no_school' || node.status === 'not_initialized';
const displayChildren = isCalendarSection && ctx.calendarMode === 'academic'
? ctx.academicTerms
: isTimetableSection && ctx.timetableView === 'term'
? ctx.timetableTermNodes
: children;
const academicEmpty = isCalendarSection
&& ctx.calendarMode === 'academic'
&& (ctx.academicCalendarStatus === 'empty' || ctx.academicCalendarStatus === 'idle');
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!expanded && displayChildren.length === 0 && canExpand && !isCalendarSection) {
setLoading(true);
try {
const loaded = await onExpand(node);
setChildren(loaded);
} finally {
setLoading(false);
}
}
setExpanded(v => !v);
};
const handleClick = () => {
if (!isSection) {
onSelect(node);
} 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);
}
}
};
// Derive action buttons per section
const ss = ctx.schoolStatus;
// School section: calendar setup (admin) or pending notice (non-admin)
const showCalendarSetup = isSchoolSection
&& ss && ss.status !== 'no_school'
&& !ss.school_has_calendar && ss.user_role === 'school_admin';
const showCalendarPending = isSchoolSection
&& ss && ss.status !== 'no_school'
&& !ss.school_has_calendar && ss.user_role !== 'school_admin';
// School section: onboarding prompt when no school linked
const showOnboard = isSchoolSection && (!ss || ss.status === 'no_school');
// Timetable section: teacher timetable setup (requires school calendar first)
const showTimetableSetup = isTimetableSection && node.status === 'empty'
&& ss && ss.status !== 'no_school'
&& ss.school_has_calendar && !ss.teacher_has_timetable;
const showLegacySetup = isTimetableSection && node.status === 'empty' && !ss;
const showTimetableEdit = isTimetableSection && node.status === 'populated'
&& ss && ss.status !== 'no_school' && !!ss.teacher_has_timetable;
const showTimetableView = isTimetableSection && node.status === 'populated';
const showClassesView = isClassesSection && node.status === 'populated';
if (isSection) {
return (
<Box>
<Box
onClick={handleClick}
sx={{
display: 'flex', alignItems: 'center',
px: 1, py: 0.6,
cursor: (canExpand || isCalendarSection) ? 'pointer' : 'default',
mt: depth === 0 ? 0.5 : 0,
borderRadius: 1,
'&:hover': (canExpand || isCalendarSection) ? { bgcolor: 'action.hover' } : {},
}}
>
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{(canExpand || (isCalendarSection && !academicEmpty)) && (
loading
? <CircularProgress size={10} />
: (
<IconButton size="small" sx={{ p: 0, color: 'text.secondary' }} onClick={handleToggle}>
{expanded
? <ExpandMore sx={{ fontSize: 14 }} />
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
</IconButton>
)
)}
{isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && !isClassesSection && (
<Box sx={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{node.status === 'no_school'
? <UnlinkedIcon sx={{ fontSize: 11, color: 'text.disabled' }} />
: node.status === 'not_initialized'
? <PendingIcon sx={{ fontSize: 11, color: 'text.disabled' }} />
: null}
</Box>
)}
</Box>
<Icon sx={{ fontSize: 13, mr: 0.75, flexShrink: 0, color: isEmpty ? 'text.disabled' : 'primary.main', opacity: isEmpty ? 0.5 : 1 }} />
<Typography
variant="caption"
sx={{
fontWeight: 600, letterSpacing: '0.04em',
textTransform: 'uppercase', fontSize: '0.68rem',
color: isEmpty ? 'text.disabled' : 'text.secondary',
flexGrow: 1, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{node.label}
</Typography>
{isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && !isClassesSection && node.status && (
<Tooltip title={STATUS_MESSAGES[node.status]} placement="right">
<Typography variant="caption" sx={{ fontSize: '0.6rem', color: 'text.disabled', ml: 0.5, flexShrink: 0 }}>
{node.status === 'no_school' ? '—' : '…'}
</Typography>
</Tooltip>
)}
{/* School section — onboarding when no school */}
{showOnboard && (
<Tooltip title="Find and join your school" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'primary.main' }}
onClick={e => { e.stopPropagation(); ctx.onOnboardSchool(); }}
>
<SetupIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
{/* Calendar section — role-aware action */}
{showCalendarSetup && (
<Tooltip title="Set up school calendar" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'primary.main' }}
onClick={e => { e.stopPropagation(); ctx.onSetupSchoolCalendar(); }}
>
<SetupIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
{showCalendarPending && (
<Tooltip title="School calendar not set up yet — contact your school admin" placement="right">
<PendingIcon sx={{ fontSize: 11, color: 'text.disabled', ml: 0.5 }} />
</Tooltip>
)}
{showTimetableSetup && (
<Tooltip title="Set up my timetable" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'primary.main' }}
onClick={e => { e.stopPropagation(); ctx.onSetupTimetable(); }}
>
<SetupIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
{showLegacySetup && (
<Tooltip title="Set up timetable" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'primary.main' }}
onClick={e => { e.stopPropagation(); ctx.onSetupTimetable(); }}
>
<SetupIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
{showTimetableEdit && (
<Tooltip title="Edit my class schedule" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'text.secondary' }}
onClick={e => { e.stopPropagation(); ctx.onSetupTimetable(); }}
>
<EditIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
{showTimetableView && (
<Tooltip title="View as lesson list" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'text.secondary' }}
onClick={e => { e.stopPropagation(); navigate('/my-lessons'); }}
>
<LaunchIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
{showClassesView && (
<Tooltip title="View my classes" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'text.secondary' }}
onClick={e => { e.stopPropagation(); navigate('/my-classes'); }}
>
<LaunchIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
</Box>
{/* Timetable view toggle */}
{isTimetableSection && node.status === 'populated' && (
<Box sx={{ px: 1.5, pb: 0.5 }}>
<ToggleButtonGroup
value={ctx.timetableView}
exclusive
onChange={(_, v) => { if (v) ctx.setTimetableView(v); }}
size="small"
sx={{ height: 22 }}
>
<ToggleButton value="class" sx={{ px: 1, fontSize: '0.6rem', textTransform: 'none' }}>
By Class
</ToggleButton>
<ToggleButton value="term" sx={{ px: 1, fontSize: '0.6rem', textTransform: 'none' }}>
By Term
</ToggleButton>
</ToggleButtonGroup>
{ctx.timetableView === 'term' && ctx.timetableTermStatus === 'loading' && (
<CircularProgress size={10} sx={{ mt: 0.5, ml: 0.5 }} />
)}
{ctx.timetableView === 'term' && ctx.timetableTermStatus === 'empty' && (
<Typography variant="caption" sx={{ display: 'block', color: 'text.disabled', fontSize: '0.6rem', mt: 0.5 }}>
No academic calendar linked to timetable
</Typography>
)}
</Box>
)}
{/* Calendar mode toggle */}
{isCalendarSection && (
<Box sx={{ px: 1.5, pb: 0.5 }}>
<ToggleButtonGroup
value={ctx.calendarMode}
exclusive
onChange={(_, v) => { if (v) ctx.setCalendarMode(v); }}
size="small"
sx={{ height: 22 }}
>
<ToggleButton value="generic" sx={{ px: 1, fontSize: '0.6rem', textTransform: 'none' }}>
Generic
</ToggleButton>
<ToggleButton value="academic" sx={{ px: 1, fontSize: '0.6rem', textTransform: 'none' }}>
Academic
</ToggleButton>
</ToggleButtonGroup>
{ctx.calendarMode === 'academic' && academicEmpty && (
<Typography variant="caption" sx={{ display: 'block', color: 'text.disabled', fontSize: '0.6rem', mt: 0.5 }}>
No academic calendar set up school calendar first
</Typography>
)}
{ctx.calendarMode === 'academic' && ctx.academicCalendarStatus === 'loading' && (
<CircularProgress size={10} sx={{ mt: 0.5, ml: 0.5 }} />
)}
</Box>
)}
{(canExpand
|| (isCalendarSection && ctx.calendarMode === 'academic' && displayChildren.length > 0)
|| (isTimetableSection && ctx.timetableView === 'term' && displayChildren.length > 0)
) && (
<Collapse in={expanded} timeout="auto">
{displayChildren.map(child => (
<TreeItem key={child.neo4j_node_id} node={child} depth={depth + 1}
onSelect={onSelect} onExpand={onExpand} />
))}
</Collapse>
)}
</Box>
);
}
// Regular navigable node
return (
<Box>
<Box
onClick={handleClick}
sx={{
display: 'flex', alignItems: 'center',
pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.35,
cursor: 'pointer', borderRadius: 1, mx: 0.5,
fontSize: '0.78rem', minHeight: 26,
bgcolor: isActive ? 'action.selected' : 'transparent',
'&:hover': { bgcolor: isActive ? 'action.selected' : 'action.hover' },
}}
>
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{canExpand && (
loading
? <CircularProgress size={10} />
: (
<IconButton size="small" sx={{ p: 0, color: 'text.secondary' }} onClick={handleToggle}>
{expanded
? <ExpandMore sx={{ fontSize: 14 }} />
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
</IconButton>
)
)}
</Box>
<Icon sx={{ fontSize: 13, mr: 0.75, flexShrink: 0, color: isActive ? 'primary.main' : 'text.secondary' }} />
<Box sx={{
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
flexGrow: 1, fontSize: '0.78rem',
color: isActive ? 'primary.main' : 'text.primary',
fontWeight: isActive ? 600 : 400,
}}>
{node.label}
</Box>
</Box>
{canExpand && (
<Collapse in={expanded} timeout="auto">
{children.map(child => (
<TreeItem key={child.neo4j_node_id} node={child} depth={depth + 1}
onSelect={onSelect} onExpand={onExpand} />
))}
</Collapse>
)}
</Box>
);
}
// ─── Main Panel ───────────────────────────────────────────────────────────────
export function CCGraphNavPanel() {
const { accessToken } = useAuth();
const { navigateToNeoNode, context } = useNavigationStore();
const [tree, setTree] = useState<TreeNode | null>(null);
const [schoolStatus, setSchoolStatus] = useState<SchoolStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [calendarMode, setCalendarMode] = useState<CalendarMode>('generic');
const [academicCalendarStatus, setAcademicCalendarStatus] = useState<NavPanelContextValue['academicCalendarStatus']>('idle');
const [academicTerms, setAcademicTerms] = useState<TreeNode[]>([]);
const [timetableView, setTimetableView] = useState<'class' | 'term'>('class');
const [timetableTermNodes, setTimetableTermNodes] = useState<TreeNode[]>([]);
const [timetableTermStatus, setTimetableTermStatus] = useState<'idle' | 'loading' | 'populated' | 'empty'>('idle');
const [timetableNodeId, setTimetableNodeId] = useState<string | undefined>();
const [timetableDb, setTimetableDb] = useState<string | undefined>();
const [calendarWizardOpen, setCalendarWizardOpen] = useState(false);
const [timetableWizardOpen, setTimetableWizardOpen] = useState(false);
const [onboardingWizardOpen, setOnboardingWizardOpen] = useState(false);
const apiBase = import.meta.env.VITE_API_BASE as string;
const activeNodeId = context.node?.type !== 'workspace' ? context.node?.id : undefined;
const fetchTree = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${apiBase}/graph/tree`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
setTree(data.tree);
} catch (err) {
logger.error('graph-nav-panel', 'Failed to load graph tree', err);
setError('Failed to load navigation tree');
} finally {
setLoading(false);
}
}, [accessToken, apiBase]);
const fetchSchoolStatus = useCallback(async () => {
if (!accessToken) return;
try {
const res = await fetch(`${apiBase}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return;
const data = await res.json();
setSchoolStatus(data);
} catch {
// non-fatal — panel still works without school status
}
}, [accessToken, apiBase]);
useEffect(() => {
if (accessToken && !tree) fetchTree();
}, [accessToken, tree, fetchTree]);
useEffect(() => {
if (!tree) return;
const ttSection = tree.children?.find(n => n.section_id === 'timetable');
if (ttSection && ttSection.node_type === 'TeacherTimetable') {
setTimetableNodeId(ttSection.neo4j_node_id);
setTimetableDb(ttSection.neo4j_db_name);
}
}, [tree]);
useEffect(() => {
if (accessToken && !schoolStatus) fetchSchoolStatus();
}, [accessToken, schoolStatus, fetchSchoolStatus]);
// Fetch timetable term nodes when switching to term view
useEffect(() => {
if (timetableView !== 'term' || !timetableNodeId || !timetableDb || !accessToken) return;
if (timetableTermStatus !== 'idle') return;
setTimetableTermStatus('loading');
const params = new URLSearchParams({
neo4j_node_id: timetableNodeId,
neo4j_db_name: timetableDb,
node_type: 'TeacherTimetable',
section_id: 'timetable-term',
});
fetch(`${apiBase}/graph/node/children?${params}`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then(r => r.json())
.then(data => {
setTimetableTermNodes(data.children || []);
setTimetableTermStatus(data.children?.length > 0 ? 'populated' : 'empty');
})
.catch(() => setTimetableTermStatus('empty'));
}, [timetableView, timetableNodeId, timetableDb, accessToken, apiBase, timetableTermStatus]);
// Fetch academic calendar when switching to academic mode
useEffect(() => {
if (calendarMode !== 'academic' || !accessToken) return;
if (academicCalendarStatus !== 'idle') return;
setAcademicCalendarStatus('loading');
fetch(`${apiBase}/graph/calendar/academic`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then(r => r.json())
.then(data => {
if (data.status === 'populated') {
setAcademicTerms(data.terms);
setAcademicCalendarStatus('populated');
} else {
setAcademicCalendarStatus(data.status || 'empty');
}
})
.catch(() => setAcademicCalendarStatus('error'));
}, [calendarMode, accessToken, apiBase, academicCalendarStatus]);
const handleSetCalendarMode = useCallback((m: CalendarMode) => {
setCalendarMode(m);
if (m === 'academic') setAcademicCalendarStatus('idle');
}, []);
const handleExpand = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
if (!accessToken) return [];
const params = new URLSearchParams({
neo4j_node_id: node.neo4j_node_id,
neo4j_db_name: node.neo4j_db_name,
node_type: node.node_type,
section_id: node.section_id || '',
});
try {
const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return [];
const data = await res.json();
return data.children || [];
} catch {
return [];
}
}, [accessToken, apiBase]);
const handleSelect = useCallback((node: TreeNode) => {
// Section nodes with artificial IDs (node_type="Section") have no canvas shape util
if (node.is_section && node.node_type === "Section") return;
navigateToNeoNode(node);
}, [navigateToNeoNode]);
const refreshAll = useCallback(() => {
setTree(null);
setSchoolStatus(null);
setAcademicCalendarStatus('idle');
setAcademicTerms([]);
}, []);
const handleCalendarWizardComplete = useCallback(() => {
logger.info('graph-nav-panel', 'School calendar setup complete');
refreshAll();
}, [refreshAll]);
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
logger.info('graph-nav-panel', 'Teacher timetable setup complete', { timetableId });
refreshAll();
}, [refreshAll]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 3 }}>
<CircularProgress size={20} />
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 1.5, fontSize: '0.78rem', color: 'error.main' }}>
{error}
</Box>
);
}
if (!tree) return null;
const ctxValue: NavPanelContextValue = {
calendarMode,
setCalendarMode: handleSetCalendarMode,
academicCalendarStatus,
academicTerms,
schoolStatus,
onSetupSchoolCalendar: () => setCalendarWizardOpen(true),
onSetupTimetable: () => setTimetableWizardOpen(true),
onOnboardSchool: () => setOnboardingWizardOpen(true),
activeNodeId,
timetableView,
setTimetableView: (v) => { setTimetableView(v); if (v === 'class') { setTimetableTermStatus('idle'); setTimetableTermNodes([]); } },
timetableTermNodes,
timetableTermStatus,
};
const defaultSchoolInfo: SchoolInfo = {
name: '', urn: '', website: '', address: {},
headteacher: '', term_dates_url: '', staff_list_url: '',
};
return (
<NavPanelContext.Provider value={ctxValue}>
<Box sx={{ pt: 0.5, pb: 2 }}>
<TreeItem
node={tree}
depth={0}
onSelect={handleSelect}
onExpand={handleExpand}
/>
</Box>
{schoolStatus?.school_info && (
<SchoolCalendarWizard
open={calendarWizardOpen}
onClose={() => setCalendarWizardOpen(false)}
onComplete={handleCalendarWizardComplete}
apiBase={apiBase}
schoolInfo={schoolStatus.school_info || defaultSchoolInfo}
/>
)}
<TeacherTimetableWizard
open={timetableWizardOpen}
onClose={() => setTimetableWizardOpen(false)}
onComplete={handleTimetableWizardComplete}
apiBase={apiBase}
periodsTemplate={schoolStatus?.periods_template || []}
timetableId={schoolStatus?.timetable_id || null}
/>
<SchoolOnboardingWizard
open={onboardingWizardOpen}
onClose={() => setOnboardingWizardOpen(false)}
onComplete={() => {
setOnboardingWizardOpen(false);
// Reload tree + school status after successful onboarding
setTree(null);
setSchoolStatus(null);
}}
apiBase={apiBase}
/>
</NavPanelContext.Provider>
);
}