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; } 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 = { 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 = { calendar: CalendarToday, timetable: TimetableIcon, classes: ClassIcon, curriculum: CurriculumIcon, journal: JournalIcon, planner: PlannerIcon, school: SchoolIcon, }; const STATUS_MESSAGES: Record = { 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({ 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; } 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(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 ( {(canExpand || (isCalendarSection && !academicEmpty)) && ( loading ? : ( {expanded ? : } ) )} {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && !isClassesSection && ( {node.status === 'no_school' ? : node.status === 'not_initialized' ? : null} )} {node.label} {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && !isClassesSection && node.status && ( {node.status === 'no_school' ? '—' : '…'} )} {/* School section — onboarding when no school */} {showOnboard && ( { e.stopPropagation(); ctx.onOnboardSchool(); }} > )} {/* Calendar section — role-aware action */} {showCalendarSetup && ( { e.stopPropagation(); ctx.onSetupSchoolCalendar(); }} > )} {showCalendarPending && ( )} {showTimetableSetup && ( { e.stopPropagation(); ctx.onSetupTimetable(); }} > )} {showLegacySetup && ( { e.stopPropagation(); ctx.onSetupTimetable(); }} > )} {showTimetableEdit && ( { e.stopPropagation(); ctx.onSetupTimetable(); }} > )} {showTimetableView && ( { e.stopPropagation(); navigate('/my-lessons'); }} > )} {showClassesView && ( { e.stopPropagation(); navigate('/my-classes'); }} > )} {/* Timetable view toggle */} {isTimetableSection && node.status === 'populated' && ( { if (v) ctx.setTimetableView(v); }} size="small" sx={{ height: 22 }} > By Class By Term {ctx.timetableView === 'term' && ctx.timetableTermStatus === 'loading' && ( )} {ctx.timetableView === 'term' && ctx.timetableTermStatus === 'empty' && ( No academic calendar linked to timetable )} )} {/* Calendar mode toggle */} {isCalendarSection && ( { if (v) ctx.setCalendarMode(v); }} size="small" sx={{ height: 22 }} > Generic Academic {ctx.calendarMode === 'academic' && academicEmpty && ( No academic calendar — set up school calendar first )} {ctx.calendarMode === 'academic' && ctx.academicCalendarStatus === 'loading' && ( )} )} {(canExpand || (isCalendarSection && ctx.calendarMode === 'academic' && displayChildren.length > 0) || (isTimetableSection && ctx.timetableView === 'term' && displayChildren.length > 0) ) && ( {displayChildren.map(child => ( ))} )} ); } // Regular navigable node return ( {canExpand && ( loading ? : ( {expanded ? : } ) )} {node.label} {canExpand && ( {children.map(child => ( ))} )} ); } // ─── Main Panel ─────────────────────────────────────────────────────────────── export function CCGraphNavPanel() { const { accessToken } = useAuth(); const { navigateToNeoNode, context } = useNavigationStore(); const [tree, setTree] = useState(null); const [schoolStatus, setSchoolStatus] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [calendarMode, setCalendarMode] = useState('generic'); const [academicCalendarStatus, setAcademicCalendarStatus] = useState('idle'); const [academicTerms, setAcademicTerms] = useState([]); const [timetableView, setTimetableView] = useState<'class' | 'term'>('class'); const [timetableTermNodes, setTimetableTermNodes] = useState([]); const [timetableTermStatus, setTimetableTermStatus] = useState<'idle' | 'loading' | 'populated' | 'empty'>('idle'); const [timetableNodeId, setTimetableNodeId] = useState(); const [timetableDb, setTimetableDb] = useState(); 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 => { 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 ( ); } if (error) { return ( {error} ); } 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 ( {schoolStatus?.school_info && ( setCalendarWizardOpen(false)} onComplete={handleCalendarWizardComplete} apiBase={apiBase} schoolInfo={schoolStatus.school_info || defaultSchoolInfo} /> )} setTimetableWizardOpen(false)} onComplete={handleTimetableWizardComplete} apiBase={apiBase} periodsTemplate={schoolStatus?.periods_template || []} timetableId={schoolStatus?.timetable_id || null} /> setOnboardingWizardOpen(false)} onComplete={() => { setOnboardingWizardOpen(false); // Reload tree + school status after successful onboarding setTree(null); setSchoolStatus(null); }} apiBase={apiBase} /> ); }