feat(phase-b): school/timetable wizards, graph nav panel, UI updates
New components: - CCGraphNavPanel: Supabase-driven navigation tree (school/timetable/calendar/classes sections), role-aware setup buttons, lazy child loading, academic/generic calendar toggle - SchoolCalendarWizard: 3-step admin-only school setup (details → term dates → daily periods) - TeacherTimetableWizard: period grid with existing slot pre-loading, edit-mode title Updated: - CCNodeSnapshotPanel: saves via Supabase storage path + accessToken - BasePanel: nav panel tab wired to CCGraphNavPanel - CCFilesPanelEnhanced: auth context fixes - CCDocumentIntelligence suite: accessToken threading, Supabase storage integration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b0c7758135
commit
83adcce951
@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||||
import { SelectChangeEvent } from '@mui/material/Select';
|
import { SelectChangeEvent } from '@mui/material/Select';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type Manifest = {
|
type Manifest = {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
@ -41,6 +41,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
combinedBundles?: Array<{ id: string }>;
|
combinedBundles?: Array<{ id: string }>;
|
||||||
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
|
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [manifest, setManifest] = useState<Manifest | null>(null);
|
const [manifest, setManifest] = useState<Manifest | null>(null);
|
||||||
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
|
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
|
||||||
const [mode, setMode] = useState<Mode>('markdown_full');
|
const [mode, setMode] = useState<Mode>('markdown_full');
|
||||||
@ -53,9 +54,9 @@ export const CCBundleViewer: React.FC<{
|
|||||||
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
|
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
|
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)}`;
|
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) => {
|
const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
|
||||||
if (!s || typeof s !== 'string') return s || '';
|
if (!s || typeof s !== 'string') return s || '';
|
||||||
@ -222,7 +223,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
setManifest(null);
|
setManifest(null);
|
||||||
if (combinedBundles && combinedBundles.length > 0) {
|
if (combinedBundles && combinedBundles.length > 0) {
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const ms: Manifest[] = [];
|
const ms: Manifest[] = [];
|
||||||
for (const b of combinedBundles) {
|
for (const b of combinedBundles) {
|
||||||
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
|
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;
|
if (!bundleId) return;
|
||||||
try {
|
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}` } });
|
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());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const rawManifest: Manifest = await res.json();
|
const rawManifest: Manifest = await res.json();
|
||||||
@ -266,7 +267,7 @@ export const CCBundleViewer: React.FC<{
|
|||||||
let textParts: string[] = [];
|
let textParts: string[] = [];
|
||||||
let jsonParts: string[] = [];
|
let jsonParts: string[] = [];
|
||||||
for (const m of combinedManifests) {
|
for (const m of combinedManifests) {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
let rel: string | undefined;
|
let rel: string | undefined;
|
||||||
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
|
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;
|
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;
|
relPath = rec?.path;
|
||||||
}
|
}
|
||||||
if (!relPath) { setContent(''); setLoading(false); return; }
|
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);
|
const url = await proxyUrl(bucket, relPath);
|
||||||
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Box, CircularProgress, IconButton } from '@mui/material';
|
import { Box, CircularProgress, IconButton } from '@mui/material';
|
||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
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 };
|
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
|
||||||
|
|
||||||
@ -43,6 +43,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
sectionRange?: { start: number; end: number };
|
sectionRange?: { start: number; end: number };
|
||||||
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
|
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
|
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
|
||||||
@ -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');
|
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||||
try {
|
try {
|
||||||
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
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) {
|
if (mRes.ok) {
|
||||||
const m: PageImagesManifest = await mRes.json();
|
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
|
// 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`, {
|
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());
|
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
|
||||||
const artefacts: Artefact[] = await artefactsRes.json();
|
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
|
// 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`, {
|
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());
|
if (!jsonRes.ok) throw new Error(await jsonRes.text());
|
||||||
const doc: DoclingJson = await jsonRes.json();
|
const doc: DoclingJson = await jsonRes.json();
|
||||||
@ -267,7 +268,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
setPageObjectUrl(cached);
|
setPageObjectUrl(cached);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
|
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok && manifest) {
|
if (!resp.ok && manifest) {
|
||||||
// Fallback to thumbnail if the full image is not accessible yet
|
// Fallback to thumbnail if the full image is not accessible yet
|
||||||
@ -369,6 +370,7 @@ export const CCDoclingViewer: React.FC<{
|
|||||||
export default CCDoclingViewer;
|
export default CCDoclingViewer;
|
||||||
|
|
||||||
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -376,7 +378,7 @@ const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
|
|||||||
let revoked: string | null = null;
|
let revoked: string | null = null;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { SelectChangeEvent } from '@mui/material/Select';
|
|||||||
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
|
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
|
||||||
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
|
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
|
||||||
import CCBundleViewer from './CCBundleViewer.tsx';
|
import CCBundleViewer from './CCBundleViewer.tsx';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type CanonicalDoclingConfig = {
|
type CanonicalDoclingConfig = {
|
||||||
pipeline: 'standard' | 'vlm' | 'asr';
|
pipeline: 'standard' | 'vlm' | 'asr';
|
||||||
@ -46,6 +46,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const { fileId } = useParams<{ fileId: string }>();
|
const { fileId } = useParams<{ fileId: string }>();
|
||||||
|
|
||||||
const validFileId = useMemo(() => fileId || '', [fileId]);
|
const validFileId = useMemo(() => fileId || '', [fileId]);
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
|
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
|
||||||
const [profile, setProfile] = useState<Profile>('default');
|
const [profile, setProfile] = useState<Profile>('default');
|
||||||
@ -148,7 +149,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const loadBundles = async () => {
|
const loadBundles = async () => {
|
||||||
if (!validFileId) return;
|
if (!validFileId) return;
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
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}` } });
|
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const arts: Artefact[] = await res.json();
|
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');
|
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||||
try {
|
try {
|
||||||
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
|
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;
|
if (!artsRes.ok) return;
|
||||||
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
|
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
|
||||||
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
||||||
if (!outlineArt) return;
|
if (!outlineArt) return;
|
||||||
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
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;
|
if (!jsonRes.ok) return;
|
||||||
const doc = await jsonRes.json();
|
const doc = await jsonRes.json();
|
||||||
@ -242,7 +243,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
const splitArt = arts.find(a => a.type === 'split_map_json');
|
const splitArt = arts.find(a => a.type === 'split_map_json');
|
||||||
if (splitArt) {
|
if (splitArt) {
|
||||||
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, {
|
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) {
|
if (smRes.ok) {
|
||||||
const sm = await smRes.json();
|
const sm = await smRes.json();
|
||||||
@ -486,7 +487,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
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 = {
|
const body: CanonicalDoclingRequest = {
|
||||||
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
|
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@ -11,7 +11,8 @@ import Schedule from '@mui/icons-material/Schedule';
|
|||||||
import Visibility from '@mui/icons-material/Visibility';
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
import Psychology from '@mui/icons-material/Psychology';
|
import Psychology from '@mui/icons-material/Psychology';
|
||||||
import Overview from '@mui/icons-material/Home';
|
import Overview from '@mui/icons-material/Home';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import CalendarViewMonth from '@mui/icons-material/CalendarViewMonth';
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type PageImagesManifest = {
|
type PageImagesManifest = {
|
||||||
@ -66,6 +67,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
fileId, selectedPage, onSelectPage, currentSection
|
fileId, selectedPage, onSelectPage, currentSection
|
||||||
}) => {
|
}) => {
|
||||||
// State
|
// State
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
||||||
@ -94,7 +96,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
|
const token = accessToken || '';
|
||||||
|
|
||||||
// Load page images manifest
|
// Load page images manifest
|
||||||
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/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<CCEnhancedFilePanelProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
|
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}` } });
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||||
import { supabase } from '../../../supabaseClient';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
|
||||||
type PageImagesManifest = {
|
type PageImagesManifest = {
|
||||||
version: number;
|
version: number;
|
||||||
@ -54,6 +54,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
selectedPage: number;
|
selectedPage: number;
|
||||||
onSelectPage: (p: number) => void;
|
onSelectPage: (p: number) => void;
|
||||||
}> = ({ fileId, selectedPage, onSelectPage }) => {
|
}> = ({ fileId, selectedPage, onSelectPage }) => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
|
||||||
@ -73,7 +74,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
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());
|
if (!mRes.ok) throw new Error(await mRes.text());
|
||||||
const m: PageImagesManifest = await mRes.json();
|
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 to load outline structure artefact (for grouping only)
|
||||||
try {
|
try {
|
||||||
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
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) {
|
if (artsRes.ok) {
|
||||||
const arts: Array<{ id: string; type: string }> = await artsRes.json();
|
const arts: Array<{ id: string; type: string }> = await artsRes.json();
|
||||||
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
|
||||||
if (outlineArt) {
|
if (outlineArt) {
|
||||||
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
|
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) {
|
if (jsonRes.ok) {
|
||||||
const outJson = await jsonRes.json();
|
const outJson = await jsonRes.json();
|
||||||
@ -118,7 +119,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
const pg = manifest.page_images[idx];
|
const pg = manifest.page_images[idx];
|
||||||
if (!pg) return undefined;
|
if (!pg) return undefined;
|
||||||
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
|
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}` } });
|
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
if (!resp.ok) return undefined;
|
if (!resp.ok) return undefined;
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
@ -150,7 +151,7 @@ export const CCFileDetailPanel: React.FC<{
|
|||||||
<IconButton size="small" onClick={async () => {
|
<IconButton size="small" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
setShowAdmin(true);
|
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 res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setAdminData(data);
|
setAdminData(data);
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { CCYoutubePanel } from './CCYoutubePanel';
|
|||||||
import { CCGraphPanel } from './CCGraphPanel';
|
import { CCGraphPanel } from './CCGraphPanel';
|
||||||
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
||||||
import { CCSearchPanel } from './CCSearchPanel'
|
import { CCSearchPanel } from './CCSearchPanel'
|
||||||
|
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel'
|
||||||
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
|
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
|
||||||
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
||||||
import './panel.css';
|
import './panel.css';
|
||||||
@ -145,7 +146,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
return createTheme({
|
return createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode,
|
mode,
|
||||||
divider: 'var(--color-divider)',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||||
@ -281,6 +281,8 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
return <CCGraphPanel />;
|
return <CCGraphPanel />;
|
||||||
case 'search':
|
case 'search':
|
||||||
return <CCSearchPanel />;
|
return <CCSearchPanel />;
|
||||||
|
case 'navigation':
|
||||||
|
return <CCGraphNavPanel />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -386,9 +388,11 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
<div className="panel-content">
|
<div className="panel-content">
|
||||||
{renderCurrentPanel()}
|
{renderCurrentPanel()}
|
||||||
</div>
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ import DescriptionIcon from '@mui/icons-material/Description';
|
|||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
|
||||||
import { supabase } from '../../../../../supabaseClient';
|
import { useAuth } from '../../../../../contexts/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
@ -75,7 +75,8 @@ interface UploadProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CCFilesPanelEnhanced: React.FC = () => {
|
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 prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||||
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
|
const [cabinets, setCabinets] = useState<Cabinet[]>([]);
|
||||||
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
|
const [selectedCabinet, setSelectedCabinet] = useState<string>('');
|
||||||
@ -109,7 +110,7 @@ export const CCFilesPanelEnhanced: React.FC = () => {
|
|||||||
|
|
||||||
const apiFetch = async (url: string, init?: RequestInitLike) => {
|
const apiFetch = async (url: string, init?: RequestInitLike) => {
|
||||||
const headers: HeadersInitLike = {
|
const headers: HeadersInitLike = {
|
||||||
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || authToken || ''}`,
|
'Authorization': `Bearer ${accessToken || ''}`,
|
||||||
...(init?.headers || {})
|
...(init?.headers || {})
|
||||||
};
|
};
|
||||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
|
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]);
|
useEffect(() => { if (selectedCabinet) loadFiles(selectedCabinet); }, [selectedCabinet]);
|
||||||
|
|
||||||
const handleSingleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSingleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@ -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<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;
|
||||||
|
activeNodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavPanelContext = createContext<NavPanelContextValue>({
|
||||||
|
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<TreeNode[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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 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 (
|
||||||
|
<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 && (
|
||||||
|
<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 && 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timetable 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>
|
||||||
|
)}
|
||||||
|
</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)) && (
|
||||||
|
<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 [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<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) => {
|
||||||
|
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 (
|
||||||
|
<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),
|
||||||
|
activeNodeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</NavPanelContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import Save from '@mui/icons-material/Save';
|
|||||||
import Reset from '@mui/icons-material/RestartAlt';
|
import Reset from '@mui/icons-material/RestartAlt';
|
||||||
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
||||||
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
||||||
import { UserNeoDBService } from '../../../../../../services/graph/userNeoDBService';
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
import { PageComponent } from '../components/pageComponent';
|
import { PageComponent } from '../components/pageComponent';
|
||||||
import { logger } from '../../../../../../debugConfig';
|
import { logger } from '../../../../../../debugConfig';
|
||||||
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
|
||||||
@ -68,6 +68,7 @@ export const CCNodeSnapshotPanel: React.FC = () => {
|
|||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const { context: navigationContext, isLoading, error } = useNavigationStore();
|
const { context: navigationContext, isLoading, error } = useNavigationStore();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
const { tldrawPreferences } = useTLDraw();
|
const { tldrawPreferences } = useTLDraw();
|
||||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@ -131,8 +132,9 @@ export const CCNodeSnapshotPanel: React.FC = () => {
|
|||||||
type: navigationContext.node.type
|
type: navigationContext.node.type
|
||||||
});
|
});
|
||||||
|
|
||||||
const dbName = UserNeoDBService.getNodeDatabaseName(navigationContext.node);
|
const storagePath = navigationContext.node.node_storage_path;
|
||||||
await NavigationSnapshotService.saveNodeSnapshotToDatabase(navigationContext.node.id, dbName, editor.store);
|
if (!storagePath) throw new Error('No storage path on current node');
|
||||||
|
await NavigationSnapshotService.saveNodeSnapshotToDatabase(storagePath, accessToken || '', editor.store);
|
||||||
|
|
||||||
addToast({
|
addToast({
|
||||||
title: 'Snapshot saved',
|
title: 'Snapshot saved',
|
||||||
|
|||||||
@ -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<string, string>;
|
||||||
|
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<string | null>(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<TermInput[]>(DEFAULT_TERMS);
|
||||||
|
|
||||||
|
const [periods, setPeriods] = useState<PeriodInput[]>(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 (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>Set Up School Calendar</DialogTitle>
|
||||||
|
<Box sx={{ px: 3 }}>
|
||||||
|
<Stepper activeStep={step} sx={{ mb: 2 }}>
|
||||||
|
{STEPS.map(label => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
|
||||||
|
</Stepper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{step === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Information</Typography>
|
||||||
|
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{schoolInfo.name || '—'}</Typography>
|
||||||
|
{schoolInfo.urn && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>URN: {schoolInfo.urn}</Typography>
|
||||||
|
)}
|
||||||
|
{addressStr && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{addressStr}</Typography>
|
||||||
|
)}
|
||||||
|
{schoolInfo.website && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{schoolInfo.website}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Additional Details</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Headteacher"
|
||||||
|
value={headteacher}
|
||||||
|
onChange={e => setHeadteacher(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="e.g. Mr J Smith"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Term Dates URL"
|
||||||
|
value={termDatesUrl}
|
||||||
|
onChange={e => setTermDatesUrl(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Link to term dates page on school website"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Staff List URL"
|
||||||
|
value={staffListUrl}
|
||||||
|
onChange={e => setStaffListUrl(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Link to staff list page on school website"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>School Year</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||||
|
<TextField label="Year Start" type="date" value={yearStart}
|
||||||
|
onChange={e => setYearStart(e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<TextField label="Year End" type="date" value={yearEnd}
|
||||||
|
onChange={e => setYearEnd(e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||||
|
<Typography variant="subtitle2">Terms</Typography>
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addTerm}>Add Term</Button>
|
||||||
|
</Box>
|
||||||
|
{terms.map((term, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||||
|
<TextField label="Term Name" value={term.name}
|
||||||
|
onChange={e => updateTerm(i, 'name', e.target.value)}
|
||||||
|
size="small" sx={{ width: 140 }} />
|
||||||
|
<TextField label="Start Date" type="date" value={term.start_date}
|
||||||
|
onChange={e => updateTerm(i, 'start_date', e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<TextField label="End Date" type="date" value={term.end_date}
|
||||||
|
onChange={e => updateTerm(i, 'end_date', e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<IconButton size="small" onClick={() => removeTerm(i)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||||
|
<Typography variant="subtitle2">Daily Period Schedule</Typography>
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addPeriod}>Add Period</Button>
|
||||||
|
</Box>
|
||||||
|
{periods.map((p, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||||
|
<TextField label="Code" value={p.code}
|
||||||
|
onChange={e => updatePeriod(i, 'code', e.target.value)}
|
||||||
|
size="small" sx={{ width: 80 }} />
|
||||||
|
<TextField label="Name" value={p.name}
|
||||||
|
onChange={e => updatePeriod(i, 'name', e.target.value)}
|
||||||
|
size="small" sx={{ width: 140 }} />
|
||||||
|
<TextField label="Start" type="time" value={p.start_time}
|
||||||
|
onChange={e => updatePeriod(i, 'start_time', e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<TextField label="End" type="time" value={p.end_time}
|
||||||
|
onChange={e => updatePeriod(i, 'end_time', e.target.value)}
|
||||||
|
size="small" InputLabelProps={{ shrink: true }} />
|
||||||
|
<FormControl size="small" sx={{ width: 130 }}>
|
||||||
|
<InputLabel>Type</InputLabel>
|
||||||
|
<Select label="Type" value={p.period_type}
|
||||||
|
onChange={e => updatePeriod(i, 'period_type', e.target.value)}>
|
||||||
|
<MenuItem value="lesson">Lesson</MenuItem>
|
||||||
|
<MenuItem value="break">Break</MenuItem>
|
||||||
|
<MenuItem value="registration">Registration</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<IconButton size="small" onClick={() => removePeriod(i)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={handleClose} disabled={saving}>Cancel</Button>
|
||||||
|
{step > 0 && (
|
||||||
|
<Button onClick={() => setStep(s => s - 1)} disabled={saving}>Back</Button>
|
||||||
|
)}
|
||||||
|
{step === 0 && (
|
||||||
|
<Button onClick={handleSaveSchoolInfo} variant="contained" disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={18} /> : 'Save & Continue'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 1 && (
|
||||||
|
<Button onClick={() => setStep(2)} variant="outlined" disabled={saving}>
|
||||||
|
Next: Daily Periods
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<Button onClick={handleSaveCalendar} variant="contained" disabled={saving}>
|
||||||
|
{saving ? <CircularProgress size={18} /> : 'Save School Calendar'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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<string, Record<string, string>> {
|
||||||
|
const g: Record<string, Record<string, string>> = {};
|
||||||
|
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<string | null>(initialTimetableId);
|
||||||
|
const [initializing, setInitializing] = useState(false);
|
||||||
|
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [grid, setGrid] = useState<Record<string, Record<string, string>>>(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 (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>
|
||||||
|
{isEditing ? 'Edit My Timetable' : 'Set Up My Timetable'}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{(initializing || loadingSlots) && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{initializing ? 'Preparing your timetable…' : 'Loading existing classes…'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!initializing && !loadingSlots && localTimetableId && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
|
||||||
|
Enter your class codes for each lesson slot (leave blank if free)
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ fontWeight: 600, minWidth: 100 }}>Period</TableCell>
|
||||||
|
{DAYS.map(d => (
|
||||||
|
<TableCell key={d} align="center" sx={{ fontWeight: 600, minWidth: 110 }}>
|
||||||
|
{d}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{lessonPeriods.map(period => (
|
||||||
|
<TableRow key={period.code}>
|
||||||
|
<TableCell>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
{period.code}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||||
|
{period.start_time}–{period.end_time}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
{DAYS.map(day => (
|
||||||
|
<TableCell key={day} align="center" sx={{ p: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="—"
|
||||||
|
value={grid[day]?.[period.code] || ''}
|
||||||
|
onChange={e => setCell(day, period.code, e.target.value)}
|
||||||
|
inputProps={{
|
||||||
|
style: { textAlign: 'center', fontSize: '0.8rem', padding: '4px 6px' },
|
||||||
|
}}
|
||||||
|
sx={{ width: 96 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={handleClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={busy || !localTimetableId}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={18} /> : 'Save Timetable'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user