From 9d78f06c973e2b604266fd3ca9f7ca24a615517e Mon Sep 17 00:00:00 2001 From: kcar Date: Tue, 26 May 2026 01:56:37 +0100 Subject: [PATCH] feat(phase-b): school onboarding wizard + GAIS school search UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchoolOnboardingWizard.tsx: new 3-step wizard (GAIS search → confirm → calendar setup) triggered from the nav panel school section when no school is linked. Calls GET /school/search for live school lookup and POST /school/register to create the institute record. - CCGraphNavPanel.tsx: add showOnboard button for no_school state, onOnboardSchool context action, wire up SchoolOnboardingWizard state. Co-Authored-By: Claude Sonnet 4.6 --- .../shared/navigation/CCGraphNavPanel.tsx | 33 +- .../navigation/SchoolOnboardingWizard.tsx | 338 ++++++++++++++++++ 2 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolOnboardingWizard.tsx diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx index 9e1e5b0..b4445ef 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/CCGraphNavPanel.tsx @@ -26,6 +26,7 @@ import { NeoGraphNode } from '../../../../../../types/navigation'; import { logger } from '../../../../../../debugConfig'; import { SchoolCalendarWizard, SchoolInfo } from './SchoolCalendarWizard'; import { TeacherTimetableWizard, PeriodTemplate } from './TeacherTimetableWizard'; +import { SchoolOnboardingWizard } from './SchoolOnboardingWizard'; type NodeStatus = 'populated' | 'empty' | 'no_school' | 'not_initialized'; type CalendarMode = 'generic' | 'academic'; @@ -97,6 +98,7 @@ interface NavPanelContextValue { schoolStatus: SchoolStatus | null; onSetupSchoolCalendar: () => void; onSetupTimetable: () => void; + onOnboardSchool: () => void; activeNodeId?: string; } @@ -108,6 +110,7 @@ const NavPanelContext = createContext({ schoolStatus: null, onSetupSchoolCalendar: () => {}, onSetupTimetable: () => {}, + onOnboardSchool: () => {}, }); // ─── TreeItem ───────────────────────────────────────────────────────────────── @@ -181,6 +184,8 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) { const showCalendarPending = isSchoolSection && ss && ss.status !== 'no_school' && !ss.school_has_calendar && ss.user_role !== 'school_admin'; + // School section: onboarding prompt when no school linked + const showOnboard = isSchoolSection && (!ss || ss.status === 'no_school'); // Timetable section: teacher timetable setup (requires school calendar first) const showTimetableSetup = isTimetableSection && node.status === 'empty' && ss && ss.status !== 'no_school' @@ -249,7 +254,19 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) { )} - {/* Timetable section — role-aware action */} + {/* School section — onboarding when no school */} + {showOnboard && ( + + { e.stopPropagation(); ctx.onOnboardSchool(); }} + > + + + + )} + {/* Calendar section — role-aware action */} {showCalendarSetup && ( setCalendarWizardOpen(true), onSetupTimetable: () => setTimetableWizardOpen(true), + onOnboardSchool: () => setOnboardingWizardOpen(true), activeNodeId, }; @@ -581,6 +600,18 @@ export function CCGraphNavPanel() { periodsTemplate={schoolStatus?.periods_template || []} timetableId={schoolStatus?.timetable_id || null} /> + + setOnboardingWizardOpen(false)} + onComplete={() => { + setOnboardingWizardOpen(false); + // Reload tree + school status after successful onboarding + setTree(null); + setSchoolStatus(null); + }} + apiBase={apiBase} + /> ); } diff --git a/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolOnboardingWizard.tsx b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolOnboardingWizard.tsx new file mode 100644 index 0000000..ae309a3 --- /dev/null +++ b/src/utils/tldraw/ui-overrides/components/shared/navigation/SchoolOnboardingWizard.tsx @@ -0,0 +1,338 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, + Button, Stepper, Step, StepLabel, Box, TextField, + Typography, CircularProgress, Alert, List, ListItem, + ListItemButton, ListItemText, Divider, InputAdornment, + Chip, +} from '@mui/material'; +import { Search as SearchIcon, School as SchoolIcon } from '@mui/icons-material'; +import { useAuth } from '../../../../../../contexts/AuthContext'; +import { SchoolCalendarWizard, SchoolInfo } from './SchoolCalendarWizard'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface GaisSchool { + urn: string; + name: string; + status: string | null; + phase: string | null; + type: string | null; + street: string | null; + locality: string | null; + town: string | null; + county: string | null; + postcode: string | null; + website: string | null; + telephone: string | null; + head_title: string | null; + head_first_name: string | null; + head_last_name: string | null; + la_name: string | null; + number_of_pupils: number | null; + region: string | null; +} + +interface Props { + open: boolean; + onClose: () => void; + onComplete: () => void; + apiBase: string; +} + +const STEPS = ['Find your school', 'Confirm details', 'Set up calendar']; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function buildAddress(g: GaisSchool): Record { + const addr: Record = {}; + if (g.street) addr.street = g.street; + if (g.locality) addr.locality = g.locality; + if (g.town) addr.town = g.town; + if (g.county) addr.county = g.county; + if (g.postcode) addr.postcode = g.postcode; + return addr; +} + +function headteacherString(g: GaisSchool): string { + return [g.head_title, g.head_first_name, g.head_last_name] + .filter(Boolean) + .join(' '); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function SchoolOnboardingWizard({ open, onClose, onComplete, apiBase }: Props) { + const { accessToken } = useAuth(); + + const [step, setStep] = useState(0); + const [searching, setSearching] = useState(false); + const [registering, setRegistering] = useState(false); + const [error, setError] = useState(null); + + // Step 0 — search + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [selected, setSelected] = useState(null); + const debounceRef = useRef | null>(null); + + // Step 1 — confirm/edit + const [editName, setEditName] = useState(''); + const [editWebsite, setEditWebsite] = useState(''); + const [editHeadteacher, setEditHeadteacher] = useState(''); + + // After registration + const [registeredSchoolInfo, setRegisteredSchoolInfo] = useState(null); + const [calendarOpen, setCalendarOpen] = useState(false); + + const reset = () => { + setStep(0); + setQuery(''); + setResults([]); + setSelected(null); + setEditName(''); + setEditWebsite(''); + setEditHeadteacher(''); + setRegisteredSchoolInfo(null); + setCalendarOpen(false); + setError(null); + }; + + const handleClose = () => { reset(); onClose(); }; + + // ── Step 0: search ──────────────────────────────────────────────────────── + + const doSearch = useCallback(async (q: string) => { + if (q.trim().length < 2) { setResults([]); return; } + setSearching(true); + setError(null); + try { + const res = await fetch( + `${apiBase}/school/search?q=${encodeURIComponent(q)}&limit=25&status=Open`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + const data = await res.json(); + if (data.status === 'ok') setResults(data.schools || []); + else setError(data.message || 'Search failed'); + } catch { + setError('Search request failed'); + } finally { + setSearching(false); + } + }, [apiBase, accessToken]); + + const handleQueryChange = (e: React.ChangeEvent) => { + const q = e.target.value; + setQuery(q); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSearch(q), 350); + }; + + const handleSelectSchool = (school: GaisSchool) => { + setSelected(school); + setEditName(school.name); + setEditWebsite(school.website || ''); + setEditHeadteacher(headteacherString(school)); + setStep(1); + }; + + // ── Step 1 → Step 2: register school ───────────────────────────────────── + + const handleRegister = async () => { + if (!selected) return; + setRegistering(true); + setError(null); + try { + const res = await fetch(`${apiBase}/school/register`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + urn: selected.urn, + name: editName, + address: buildAddress(selected), + website: editWebsite, + headteacher: editHeadteacher, + }), + }); + const data = await res.json(); + if (data.status === 'ok' || data.status === 'already_exists') { + const info: SchoolInfo = { + name: editName, + urn: selected.urn, + website: editWebsite, + address: buildAddress(selected), + headteacher: editHeadteacher, + term_dates_url: '', + staff_list_url: '', + }; + setRegisteredSchoolInfo(info); + setStep(2); + } else { + setError(data.message || 'Registration failed'); + } + } catch { + setError('Registration request failed'); + } finally { + setRegistering(false); + } + }; + + // ── Step 2: calendar wizard completes → done ────────────────────────────── + + const handleCalendarComplete = () => { + setCalendarOpen(false); + reset(); + onComplete(); + }; + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + <> + + + + + Set up your school + + + + + + {STEPS.map(label => ( + + {label} + + ))} + + + {error && {error}} + + {step === 0 && ( + + + {searching ? : } + + ), + }} + sx={{ mb: 1 }} + /> + {results.length > 0 && ( + + {results.map((school, idx) => ( + + {idx > 0 && } + + handleSelectSchool(school)}> + + + {[school.town, school.county, school.postcode].filter(Boolean).join(', ')} + + {school.phase && ( + + )} + + } + primaryTypographyProps={{ variant: 'body2', fontWeight: 500 }} + /> + + + + ))} + + )} + {!searching && query.length >= 2 && results.length === 0 && ( + + No open schools found. Try a different name or postcode. + + )} + + )} + + {step === 1 && selected && ( + + + Review and edit your school details before registering. + + setEditName(e.target.value)} + size="small" + /> + setEditWebsite(e.target.value)} + size="small" + placeholder="https://" + /> + setEditHeadteacher(e.target.value)} + size="small" + /> + + Address from Edubase + {Object.values(buildAddress(selected)).filter(Boolean).join(', ')} + + + URN: {selected.urn} · {selected.phase} · {selected.type} + + + )} + + + + {step === 0 && ( + + )} + {step === 1 && ( + <> + + + + + )} + + + + {/* Step 2: calendar setup opens as a separate wizard */} + {registeredSchoolInfo && ( + { reset(); onClose(); }} + onComplete={handleCalendarComplete} + apiBase={apiBase} + schoolInfo={registeredSchoolInfo} + /> + )} + + ); +}