Files
app/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx
T
CC Worker 765573163d feat(ts): R4 TypeScript hardening H1-H6 — reduce errors 177→152
H1: CCGraphNavPanel — useMemo import + LogCategory fix
H2: CCTimetableLessonNodeShapeUtil — props type alignment (cc-graph-props.ts)
H3: cc-graph-shapes — add missing CCTimetableLessonNode* import (was never imported)
H4: CCNodeSnapshotPanel — RestartAlt import from @mui/icons-material
H5: CCExamMarkerPanel + CCFilesPanelEnhanced — BlobPart cast + webkitdirectory @ts-ignore
H6: TranscriptionManager — setTranscriptionCallback interface fix
Plus: CCExportPdfButton BlobPart cast, SimpleUploadTest icon imports, graph-sidebar LogCategory

tsc before: 177 (master)
tsc after:  152 (reduction of 25 errors, well below 160 acceptable threshold)
2026-06-01 02:29:38 +00:00

744 lines
32 KiB
TypeScript

import React, { useState, useEffect, useCallback, createContext, useContext, useMemo } 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, bootstrapData } = useAuth();
const navigate = useNavigate();
const { navigateToNeoNode, context } = useNavigationStore();
const [tree, setTree] = useState<TreeNode | 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('navigation-panel', 'Failed to load graph tree', err);
setError('Failed to load navigation tree');
} finally {
setLoading(false);
}
}, [accessToken, apiBase]);
const schoolStatus = useMemo<SchoolStatus | null>(() => {
if (!bootstrapData) return null;
const membershipRole = bootstrapData.active_institute?.membership_role ?? undefined;
const activeMembership = bootstrapData.memberships?.find(
(m) => m.institute_id === bootstrapData.active_institute?.id && m.is_active
);
return {
status: bootstrapData.school_status,
user_role: membershipRole,
school_id: bootstrapData.active_institute?.id ?? undefined,
school_has_calendar: bootstrapData.calendar_status.available && !bootstrapData.calendar_status.needs_setup,
teacher_has_timetable: bootstrapData.timetable_status.available && !bootstrapData.timetable_status.needs_setup,
timetable_id: bootstrapData.timetable_status.teacher_timetable_id,
periods_template: [],
school_info: activeMembership?.institute ? {
name: activeMembership.institute.name,
urn: activeMembership.institute.urn ?? '',
website: '',
address: {},
headteacher: '',
term_dates_url: '',
staff_list_url: '',
} : undefined,
};
}, [bootstrapData]);
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]);
// 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;
// SubjectClass nodes don't yet exist in Neo4j — navigate to class detail page
if (node.node_type === "SubjectClass") {
navigate(`/classes/${node.neo4j_node_id}`);
return;
}
navigateToNeoNode(node);
}, [navigateToNeoNode, navigate]);
const refreshAll = useCallback(() => {
setTree(null);
setAcademicCalendarStatus('idle');
setAcademicTerms([]);
}, []);
const handleCalendarWizardComplete = useCallback(() => {
logger.info('navigation-panel', 'School calendar setup complete');
refreshAll();
}, [refreshAll]);
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
logger.info('navigation-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 after successful onboarding; bootstrap state refreshes from AuthContext
setTree(null);
}}
apiBase={apiBase}
/>
</NavPanelContext.Provider>
);
}