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>
This commit is contained in:
kcar 2026-05-27 12:12:59 +01:00
parent 510bef02b6
commit 98a4212e46

View File

@ -102,6 +102,10 @@ interface NavPanelContextValue {
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>({
@ -113,6 +117,10 @@ const NavPanelContext = createContext<NavPanelContextValue>({
onSetupSchoolCalendar: () => {},
onSetupTimetable: () => {},
onOnboardSchool: () => {},
timetableView: 'class',
setTimetableView: () => {},
timetableTermNodes: [],
timetableTermStatus: 'idle',
});
// ─── TreeItem ─────────────────────────────────────────────────────────────────
@ -150,6 +158,8 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
const displayChildren = isCalendarSection && ctx.calendarMode === 'academic'
? ctx.academicTerms
: isTimetableSection && ctx.timetableView === 'term'
? ctx.timetableTermNodes
: children;
const academicEmpty = isCalendarSection
@ -349,6 +359,34 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
)}
</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 }}>
@ -377,7 +415,10 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
</Box>
)}
{(canExpand || (isCalendarSection && ctx.calendarMode === 'academic' && displayChildren.length > 0)) && (
{(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}
@ -452,6 +493,11 @@ export function CCGraphNavPanel() {
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);
@ -497,10 +543,41 @@ export function CCGraphNavPanel() {
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;
@ -547,7 +624,9 @@ export function CCGraphNavPanel() {
}, [accessToken, apiBase]);
const handleSelect = useCallback((node: TreeNode) => {
if (!node.is_section || node.neo4j_node_id) navigateToNeoNode(node);
// 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(() => {
@ -595,6 +674,10 @@ export function CCGraphNavPanel() {
onSetupTimetable: () => setTimetableWizardOpen(true),
onOnboardSchool: () => setOnboardingWizardOpen(true),
activeNodeId,
timetableView,
setTimetableView: (v) => { setTimetableView(v); if (v === 'class') { setTimetableTermStatus('idle'); setTimetableTermNodes([]); } },
timetableTermNodes,
timetableTermStatus,
};
const defaultSchoolInfo: SchoolInfo = {