- 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>
732 lines
32 KiB
TypeScript
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>
|
|
);
|
|
}
|