diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx index 12bcf6a..b851d94 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCBundleViewer.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material'; import { SelectChangeEvent } from '@mui/material/Select'; -import { supabase } from '../../../supabaseClient'; +import { useAuth } from '../../../contexts/AuthContext'; type Manifest = { bucket: string; @@ -41,6 +41,7 @@ export const CCBundleViewer: React.FC<{ currentPage?: number; combinedBundles?: Array<{ id: string }>; }> = ({ fileId, bundleId, currentPage, combinedBundles }) => { + const { accessToken } = useAuth(); const [manifest, setManifest] = useState(null); const [combinedManifests, setCombinedManifests] = useState(null); const [mode, setMode] = useState('markdown_full'); @@ -53,9 +54,9 @@ export const CCBundleViewer: React.FC<{ const API_BASE_FALLBACK = 'http://127.0.0.1:8080'; const proxyUrl = useCallback(async (bucket: string, relPath: string) => { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`; - }, [API_BASE]); + }, [API_BASE, accessToken]); const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => { if (!s || typeof s !== 'string') return s || ''; @@ -222,7 +223,7 @@ export const CCBundleViewer: React.FC<{ setManifest(null); if (combinedBundles && combinedBundles.length > 0) { try { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const ms: Manifest[] = []; for (const b of combinedBundles) { const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } }); @@ -239,7 +240,7 @@ export const CCBundleViewer: React.FC<{ } if (!bundleId) return; try { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(await res.text()); const rawManifest: Manifest = await res.json(); @@ -266,7 +267,7 @@ export const CCBundleViewer: React.FC<{ let textParts: string[] = []; let jsonParts: string[] = []; for (const m of combinedManifests) { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; let rel: string | undefined; if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full; else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full; @@ -348,7 +349,7 @@ export const CCBundleViewer: React.FC<{ relPath = rec?.path; } if (!relPath) { setContent(''); setLoading(false); return; } - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const url = await proxyUrl(bucket, relPath); let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(await res.text()); diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx index faaadf8..fa03301 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCDoclingViewer.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Box, CircularProgress, IconButton } from '@mui/material'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; -import { supabase } from '../../../supabaseClient'; +import { useAuth } from '../../../contexts/AuthContext'; type Artefact = { id: string; type: string; rel_path: string; created_at: string }; @@ -43,6 +43,7 @@ export const CCDoclingViewer: React.FC<{ hideToolbar?: boolean; sectionRange?: { start: number; end: number }; }> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => { + const { accessToken } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [images, setImages] = useState>([]); @@ -184,7 +185,7 @@ export const CCDoclingViewer: React.FC<{ const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); try { const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (mRes.ok) { const m: PageImagesManifest = await mRes.json(); @@ -199,7 +200,7 @@ export const CCDoclingViewer: React.FC<{ // Legacy: Load artefacts for file to find docling JSON artefacts const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!artefactsRes.ok) throw new Error(await artefactsRes.text()); const artefacts: Artefact[] = await artefactsRes.json(); @@ -216,7 +217,7 @@ export const CCDoclingViewer: React.FC<{ // Download artefact JSON via backend (service-role) to avoid RLS issues const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!jsonRes.ok) throw new Error(await jsonRes.text()); const doc: DoclingJson = await jsonRes.json(); @@ -267,7 +268,7 @@ export const CCDoclingViewer: React.FC<{ setPageObjectUrl(cached); return; } - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok && manifest) { // Fallback to thumbnail if the full image is not accessible yet @@ -369,6 +370,7 @@ export const CCDoclingViewer: React.FC<{ export default CCDoclingViewer; const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => { + const { accessToken } = useAuth(); const [blobUrl, setBlobUrl] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -376,7 +378,7 @@ const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => { let revoked: string | null = null; const load = async () => { try { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence.tsx index dd61f28..fd0f1f0 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence.tsx @@ -6,7 +6,7 @@ import { SelectChangeEvent } from '@mui/material/Select'; import { CCDoclingViewer } from './CCDoclingViewer.tsx'; import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx'; import CCBundleViewer from './CCBundleViewer.tsx'; -import { supabase } from '../../../supabaseClient'; +import { useAuth } from '../../../contexts/AuthContext'; type CanonicalDoclingConfig = { pipeline: 'standard' | 'vlm' | 'asr'; @@ -46,6 +46,7 @@ export const CCDocumentIntelligence: React.FC = () => { const { fileId } = useParams<{ fileId: string }>(); const validFileId = useMemo(() => fileId || '', [fileId]); + const { accessToken } = useAuth(); const [page, setPage] = useState(1); const [outlineOptions, setOutlineOptions] = useState>([]); const [profile, setProfile] = useState('default'); @@ -148,7 +149,7 @@ export const CCDocumentIntelligence: React.FC = () => { const loadBundles = async () => { if (!validFileId) return; const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) return; const arts: Artefact[] = await res.json(); @@ -225,14 +226,14 @@ export const CCDocumentIntelligence: React.FC = () => { const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); try { const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!artsRes.ok) return; const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json(); const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy'); if (!outlineArt) return; const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!jsonRes.ok) return; const doc = await jsonRes.json(); @@ -242,7 +243,7 @@ export const CCDocumentIntelligence: React.FC = () => { const splitArt = arts.find(a => a.type === 'split_map_json'); if (splitArt) { const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (smRes.ok) { const sm = await smRes.json(); @@ -486,7 +487,7 @@ export const CCDocumentIntelligence: React.FC = () => { try { setBusy(true); const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const body: CanonicalDoclingRequest = { use_split_map: selectedSectionId === 'full' ? autoSplit : false, config: { diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx index 20b5bdb..aa8295b 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCEnhancedFilePanel.tsx @@ -11,7 +11,8 @@ import Schedule from '@mui/icons-material/Schedule'; import Visibility from '@mui/icons-material/Visibility'; import Psychology from '@mui/icons-material/Psychology'; import Overview from '@mui/icons-material/Home'; -import { supabase } from '../../../supabaseClient'; +import CalendarViewMonth from '@mui/icons-material/CalendarViewMonth'; +import { useAuth } from '../../../contexts/AuthContext'; // Types type PageImagesManifest = { @@ -66,6 +67,7 @@ export const CCEnhancedFilePanel: React.FC = ({ fileId, selectedPage, onSelectPage, currentSection }) => { // State + const { accessToken } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [manifest, setManifest] = useState(null); @@ -94,7 +96,7 @@ export const CCEnhancedFilePanel: React.FC = ({ setError(null); try { - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; // Load page images manifest const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { @@ -198,7 +200,7 @@ export const CCEnhancedFilePanel: React.FC = ({ try { const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`; - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) return undefined; diff --git a/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx b/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx index 1706d56..8d7b509 100644 --- a/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx +++ b/src/pages/tldraw/CCDocumentIntelligence/CCFileDetailPanel.tsx @@ -5,7 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; -import { supabase } from '../../../supabaseClient'; +import { useAuth } from '../../../contexts/AuthContext'; type PageImagesManifest = { version: number; @@ -54,6 +54,7 @@ export const CCFileDetailPanel: React.FC<{ selectedPage: number; onSelectPage: (p: number) => void; }> = ({ fileId, selectedPage, onSelectPage }) => { + const { accessToken } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [manifest, setManifest] = useState(null); @@ -73,7 +74,7 @@ export const CCFileDetailPanel: React.FC<{ setError(null); try { const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!mRes.ok) throw new Error(await mRes.text()); const m: PageImagesManifest = await mRes.json(); @@ -82,14 +83,14 @@ export const CCFileDetailPanel: React.FC<{ // Try to load outline structure artefact (for grouping only) try { const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (artsRes.ok) { const arts: Array<{ id: string; type: string }> = await artsRes.json(); const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy'); if (outlineArt) { const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, { - headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } + headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (jsonRes.ok) { const outJson = await jsonRes.json(); @@ -118,7 +119,7 @@ export const CCFileDetailPanel: React.FC<{ const pg = manifest.page_images[idx]; if (!pg) return undefined; const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`; - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok) return undefined; const blob = await resp.blob(); @@ -150,7 +151,7 @@ export const CCFileDetailPanel: React.FC<{ { try { setShowAdmin(true); - const token = (await supabase.auth.getSession()).data.session?.access_token || ''; + const token = accessToken || ''; const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } }); const data = await res.json(); setAdminData(data); diff --git a/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx index decb5f1..08a6b25 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx @@ -34,6 +34,7 @@ import { CCYoutubePanel } from './CCYoutubePanel'; import { CCGraphPanel } from './CCGraphPanel'; import { CCExamMarkerPanel } from './CCExamMarkerPanel'; import { CCSearchPanel } from './CCSearchPanel' +import { CCGraphNavPanel } from './navigation/CCGraphNavPanel' import { CCTranscriptionPanel } from './CCTranscriptionPanel' import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles'; import './panel.css'; @@ -145,7 +146,6 @@ export const BasePanel: React.FC = ({ return createTheme({ palette: { mode, - divider: 'var(--color-divider)', }, }); }, [tldrawPreferences?.colorScheme, prefersDarkMode]); @@ -281,6 +281,8 @@ export const BasePanel: React.FC = ({ return ; case 'search': return ; + case 'navigation': + return ; default: return null; } @@ -386,9 +388,11 @@ export const BasePanel: React.FC = ({ +
{renderCurrentPanel()}
+
)} diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanelEnhanced.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanelEnhanced.tsx index 842c1ad..15ab2a3 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanelEnhanced.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCFilesPanelEnhanced.tsx @@ -35,7 +35,7 @@ import DescriptionIcon from '@mui/icons-material/Description'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import { useTLDraw } from '../../../../../contexts/TLDrawContext'; -import { supabase } from '../../../../../supabaseClient'; +import { useAuth } from '../../../../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; import { pickDirectory, @@ -75,7 +75,8 @@ interface UploadProgress { } export const CCFilesPanelEnhanced: React.FC = () => { - const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string }; + const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } }; + const { user: authUser, accessToken } = useAuth(); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const [cabinets, setCabinets] = useState([]); const [selectedCabinet, setSelectedCabinet] = useState(''); @@ -109,7 +110,7 @@ export const CCFilesPanelEnhanced: React.FC = () => { const apiFetch = async (url: string, init?: RequestInitLike) => { const headers: HeadersInitLike = { - 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`, + 'Authorization': `Bearer ${accessToken || ''}`, ...(init?.headers || {}) }; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; @@ -140,7 +141,7 @@ export const CCFilesPanelEnhanced: React.FC = () => { } }; - useEffect(() => { loadCabinets(); }, []); + useEffect(() => { if (authUser?.id) { loadCabinets(); } }, [authUser?.id]); useEffect(() => { if (selectedCabinet) loadFiles(selectedCabinet); }, [selectedCabinet]); const handleSingleUpload = async (e: React.ChangeEvent) => { diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx new file mode 100644 index 0000000..9e1e5b0 --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx @@ -0,0 +1,586 @@ +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, +} from '@mui/icons-material'; +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'; + +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; + activeNodeId?: string; +} + +const NavPanelContext = createContext({ + calendarMode: 'generic', + setCalendarMode: () => {}, + academicCalendarStatus: 'idle', + academicTerms: [], + schoolStatus: null, + onSetupSchoolCalendar: () => {}, + onSetupTimetable: () => {}, +}); + +// ─── 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 [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 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.node_type !== 'AcademicWeek' + && 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 + : 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 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'; + // 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; + + if (isSection) { + return ( + + + + {(canExpand || (isCalendarSection && !academicEmpty)) && ( + loading + ? + : ( + + {expanded + ? + : } + + ) + )} + {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && ( + + {node.status === 'no_school' + ? + : node.status === 'not_initialized' + ? + : null} + + )} + + + + + + {node.label} + + + {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && node.status && ( + + + {node.status === 'no_school' ? '—' : '…'} + + + )} + + {/* Timetable 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(); }} + > + + + + )} + + + {/* 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)) && ( + + {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 [calendarWizardOpen, setCalendarWizardOpen] = useState(false); + const [timetableWizardOpen, setTimetableWizardOpen] = 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 (accessToken && !schoolStatus) fetchSchoolStatus(); + }, [accessToken, schoolStatus, fetchSchoolStatus]); + + // 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) => { + if (!node.is_section) 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), + activeNodeId, + }; + + 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} + /> + + ); +} diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx index 9d154a5..763ffaf 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCNodeSnapshotPanel.tsx @@ -4,7 +4,7 @@ import Save from '@mui/icons-material/Save'; import Reset from '@mui/icons-material/RestartAlt'; import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw'; import { useNavigationStore } from '../../../../../../stores/navigationStore'; -import { UserNeoDBService } from '../../../../../../services/graph/userNeoDBService'; +import { useAuth } from '../../../../../../contexts/AuthContext'; import { PageComponent } from '../components/pageComponent'; import { logger } from '../../../../../../debugConfig'; import { useTLDraw } from '../../../../../../contexts/TLDrawContext'; @@ -68,6 +68,7 @@ export const CCNodeSnapshotPanel: React.FC = () => { const editor = useEditor(); const { addToast } = useToasts(); const { context: navigationContext, isLoading, error } = useNavigationStore(); + const { accessToken } = useAuth(); const { tldrawPreferences } = useTLDraw(); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const [isSaving, setIsSaving] = useState(false); @@ -131,8 +132,9 @@ export const CCNodeSnapshotPanel: React.FC = () => { type: navigationContext.node.type }); - const dbName = UserNeoDBService.getNodeDatabaseName(navigationContext.node); - await NavigationSnapshotService.saveNodeSnapshotToDatabase(navigationContext.node.id, dbName, editor.store); + const storagePath = navigationContext.node.node_storage_path; + if (!storagePath) throw new Error('No storage path on current node'); + await NavigationSnapshotService.saveNodeSnapshotToDatabase(storagePath, accessToken || '', editor.store); addToast({ title: 'Snapshot saved', diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx new file mode 100644 index 0000000..2f16a4b --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolCalendarWizard.tsx @@ -0,0 +1,316 @@ +import React, { useState } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, + Button, Stepper, Step, StepLabel, Box, TextField, + Typography, IconButton, Select, MenuItem, FormControl, + InputLabel, CircularProgress, Alert, Divider, +} from '@mui/material'; +import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import { useAuth } from '../../../../../../contexts/AuthContext'; + +interface TermInput { + name: string; + term_number: number; + start_date: string; + end_date: string; +} + +interface PeriodInput { + code: string; + name: string; + start_time: string; + end_time: string; + period_type: 'lesson' | 'break' | 'registration'; +} + +export interface SchoolInfo { + name: string; + urn: string; + website: string; + address: Record; + headteacher: string; + term_dates_url: string; + staff_list_url: string; +} + +const DEFAULT_TERMS: TermInput[] = [ + { name: 'Autumn', term_number: 1, start_date: '2025-09-03', end_date: '2025-12-19' }, + { name: 'Spring', term_number: 2, start_date: '2026-01-06', end_date: '2026-04-01' }, + { name: 'Summer', term_number: 3, start_date: '2026-04-22', end_date: '2026-07-22' }, +]; + +const DEFAULT_PERIODS: PeriodInput[] = [ + { code: 'REG', name: 'Registration', start_time: '08:45', end_time: '09:00', period_type: 'registration' }, + { code: 'P1', name: 'Period 1', start_time: '09:00', end_time: '10:00', period_type: 'lesson' }, + { code: 'P2', name: 'Period 2', start_time: '10:00', end_time: '11:00', period_type: 'lesson' }, + { code: 'BREAK', name: 'Break', start_time: '11:00', end_time: '11:20', period_type: 'break' }, + { code: 'P3', name: 'Period 3', start_time: '11:20', end_time: '12:20', period_type: 'lesson' }, + { code: 'P4', name: 'Period 4', start_time: '12:20', end_time: '13:20', period_type: 'lesson' }, + { code: 'LUNCH', name: 'Lunch', start_time: '13:20', end_time: '14:05', period_type: 'break' }, + { code: 'P5', name: 'Period 5', start_time: '14:05', end_time: '15:05', period_type: 'lesson' }, +]; + +interface Props { + open: boolean; + onClose: () => void; + onComplete: () => void; + apiBase: string; + schoolInfo: SchoolInfo; +} + +export function SchoolCalendarWizard({ open, onClose, onComplete, apiBase, schoolInfo }: Props) { + const { accessToken } = useAuth(); + const [step, setStep] = useState(0); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const [headteacher, setHeadteacher] = useState(schoolInfo.headteacher || ''); + const [termDatesUrl, setTermDatesUrl] = useState(schoolInfo.term_dates_url || ''); + const [staffListUrl, setStaffListUrl] = useState(schoolInfo.staff_list_url || ''); + + const [yearStart, setYearStart] = useState('2025-09-01'); + const [yearEnd, setYearEnd] = useState('2026-07-31'); + const [terms, setTerms] = useState(DEFAULT_TERMS); + + const [periods, setPeriods] = useState(DEFAULT_PERIODS); + + const addTerm = () => setTerms(prev => [...prev, { + name: `Term ${prev.length + 1}`, + term_number: prev.length + 1, + start_date: '', + end_date: '', + }]); + + const removeTerm = (i: number) => setTerms(prev => + prev.filter((_, idx) => idx !== i).map((t, idx) => ({ ...t, term_number: idx + 1 })) + ); + + const updateTerm = (i: number, field: keyof TermInput, value: string) => + setTerms(prev => prev.map((t, idx) => idx === i ? { ...t, [field]: value } : t)); + + const addPeriod = () => setPeriods(prev => [...prev, { + code: `P${prev.length + 1}`, + name: `Period ${prev.length + 1}`, + start_time: '', + end_time: '', + period_type: 'lesson', + }]); + + const removePeriod = (i: number) => setPeriods(prev => prev.filter((_, idx) => idx !== i)); + + const updatePeriod = (i: number, field: keyof PeriodInput, value: string) => + setPeriods(prev => prev.map((p, idx) => idx === i ? { ...p, [field]: value } : p)); + + const handleSaveSchoolInfo = async () => { + if (!accessToken) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`${apiBase}/school/info`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ headteacher, term_dates_url: termDatesUrl, staff_list_url: staffListUrl }), + }); + const data = await res.json(); + if (data.status === 'ok') { + setStep(1); + } else { + setError(data.message || 'Failed to save school info'); + } + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + const handleSaveCalendar = async () => { + if (!accessToken) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`${apiBase}/timetable/setup`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ year_start: yearStart, year_end: yearEnd, terms, periods }), + }); + const data = await res.json(); + if (data.status === 'ok') { + onComplete(); + handleClose(); + } else { + setError(data.message || 'Calendar setup failed'); + } + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + const handleClose = () => { + setStep(0); + setError(null); + onClose(); + }; + + const addr = schoolInfo.address || {}; + const addressStr = [addr.street, addr.town, addr.county, addr.postcode].filter(Boolean).join(', '); + + const STEPS = ['School Details', 'Academic Calendar', 'Daily Periods']; + + return ( + + Set Up School Calendar + + + {STEPS.map(label => {label})} + + + + + {error && {error}} + + {step === 0 && ( + + School Information + + {schoolInfo.name || '—'} + {schoolInfo.urn && ( + URN: {schoolInfo.urn} + )} + {addressStr && ( + {addressStr} + )} + {schoolInfo.website && ( + {schoolInfo.website} + )} + + + Additional Details + + setHeadteacher(e.target.value)} + size="small" + fullWidth + placeholder="e.g. Mr J Smith" + /> + setTermDatesUrl(e.target.value)} + size="small" + fullWidth + placeholder="Link to term dates page on school website" + /> + setStaffListUrl(e.target.value)} + size="small" + fullWidth + placeholder="Link to staff list page on school website" + /> + + + )} + + {step === 1 && ( + + School Year + + setYearStart(e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + setYearEnd(e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + + + + Terms + + + {terms.map((term, i) => ( + + updateTerm(i, 'name', e.target.value)} + size="small" sx={{ width: 140 }} /> + updateTerm(i, 'start_date', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + updateTerm(i, 'end_date', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + removeTerm(i)}> + + + + ))} + + )} + + {step === 2 && ( + + + Daily Period Schedule + + + {periods.map((p, i) => ( + + updatePeriod(i, 'code', e.target.value)} + size="small" sx={{ width: 80 }} /> + updatePeriod(i, 'name', e.target.value)} + size="small" sx={{ width: 140 }} /> + updatePeriod(i, 'start_time', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + updatePeriod(i, 'end_time', e.target.value)} + size="small" InputLabelProps={{ shrink: true }} /> + + Type + + + removePeriod(i)}> + + + + ))} + + )} + + + + + {step > 0 && ( + + )} + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + + + ); +} diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx new file mode 100644 index 0000000..0877f58 --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/TeacherTimetableWizard.tsx @@ -0,0 +1,244 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, + Button, Box, TextField, Typography, Table, TableHead, + TableBody, TableRow, TableCell, CircularProgress, Alert, +} from '@mui/material'; +import { useAuth } from '../../../../../../contexts/AuthContext'; + +export interface PeriodTemplate { + code: string; + name: string; + start_time: string; + end_time: string; + period_type: string; +} + +const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; + +function emptyGrid(): Record> { + const g: Record> = {}; + DAYS.forEach(d => { g[d] = {}; }); + return g; +} + +interface Props { + open: boolean; + onClose: () => void; + onComplete: (timetableId: string) => void; + apiBase: string; + periodsTemplate: PeriodTemplate[]; + timetableId: string | null; +} + +export function TeacherTimetableWizard({ + open, + onClose, + onComplete, + apiBase, + periodsTemplate, + timetableId: initialTimetableId, +}: Props) { + const { accessToken } = useAuth(); + const [localTimetableId, setLocalTimetableId] = useState(initialTimetableId); + const [initializing, setInitializing] = useState(false); + const [loadingSlots, setLoadingSlots] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [grid, setGrid] = useState>>(emptyGrid); + const slotsLoadedRef = useRef(false); + + const lessonPeriods = periodsTemplate.filter(p => p.period_type === 'lesson'); + const isEditing = !!initialTimetableId; + + // Reset when dialog opens + useEffect(() => { + if (!open) { + slotsLoadedRef.current = false; + return; + } + setLocalTimetableId(initialTimetableId); + setGrid(emptyGrid()); + setError(null); + slotsLoadedRef.current = false; + }, [open, initialTimetableId]); + + // Auto-create TeacherTimetable node if not yet done + useEffect(() => { + if (!open || localTimetableId || !accessToken || initializing) return; + setInitializing(true); + setError(null); + fetch(`${apiBase}/timetable/init`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'ok') { + setLocalTimetableId(data.timetable_id); + } else { + setError(data.message || 'Could not initialize timetable. Has an admin set up the school calendar?'); + } + }) + .catch(e => setError(e.message)) + .finally(() => setInitializing(false)); + }, [open, localTimetableId, accessToken, apiBase, initializing]); + + // Load existing slots when editing + useEffect(() => { + if (!open || !localTimetableId || !accessToken || slotsLoadedRef.current || loadingSlots) return; + slotsLoadedRef.current = true; + setLoadingSlots(true); + fetch(`${apiBase}/timetable/slots`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'ok' && Array.isArray(data.slots) && data.slots.length > 0) { + const g = emptyGrid(); + for (const slot of data.slots) { + if (g[slot.day_of_week]) { + g[slot.day_of_week][slot.period_code] = slot.subject_class || ''; + } + } + setGrid(g); + } + }) + .catch(() => {}) + .finally(() => setLoadingSlots(false)); + }, [open, localTimetableId, accessToken, apiBase, loadingSlots]); + + const setCell = (day: string, code: string, value: string) => { + setGrid(prev => ({ ...prev, [day]: { ...prev[day], [code]: value } })); + }; + + const handleSave = async () => { + if (!accessToken || !localTimetableId) return; + setSaving(true); + setError(null); + try { + const slots = []; + for (const day of DAYS) { + for (const period of lessonPeriods) { + const cls = (grid[day]?.[period.code] || '').trim(); + if (cls) { + slots.push({ + day_of_week: day, + period_code: period.code, + subject_class: cls, + start_time: period.start_time, + end_time: period.end_time, + }); + } + } + } + const res = await fetch(`${apiBase}/timetable/slots`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ timetable_id: localTimetableId, slots }), + }); + const data = await res.json(); + if (data.status === 'ok') { + onComplete(localTimetableId); + handleClose(); + } else { + setError(data.message || 'Save failed'); + } + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + const handleClose = () => { + setError(null); + onClose(); + }; + + const busy = initializing || loadingSlots || saving; + + return ( + + + {isEditing ? 'Edit My Timetable' : 'Set Up My Timetable'} + + + + {error && {error}} + + {(initializing || loadingSlots) && ( + + + + {initializing ? 'Preparing your timetable…' : 'Loading existing classes…'} + + + )} + + {!initializing && !loadingSlots && localTimetableId && ( + + + Enter your class codes for each lesson slot (leave blank if free) + + + + + + Period + {DAYS.map(d => ( + + {d} + + ))} + + + + {lessonPeriods.map(period => ( + + + + + {period.code} + + + {period.start_time}–{period.end_time} + + + + {DAYS.map(day => ( + + setCell(day, period.code, e.target.value)} + inputProps={{ + style: { textAlign: 'center', fontSize: '0.8rem', padding: '4px 6px' }, + }} + sx={{ width: 96 }} + /> + + ))} + + ))} + +
+
+
+ )} +
+ + + + + +
+ ); +}