feat(phase-b): school onboarding wizard + GAIS school search UI
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
b0b2a7f2c3
commit
9d78f06c97
@ -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<NavPanelContextValue>({
|
||||
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) {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Timetable section — role-aware action */}
|
||||
{/* School section — onboarding when no school */}
|
||||
{showOnboard && (
|
||||
<Tooltip title="Find and join your school" placement="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ p: 0.25, ml: 0.5, color: 'primary.main' }}
|
||||
onClick={e => { e.stopPropagation(); ctx.onOnboardSchool(); }}
|
||||
>
|
||||
<SetupIcon sx={{ fontSize: 13 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Calendar section — role-aware action */}
|
||||
{showCalendarSetup && (
|
||||
<Tooltip title="Set up school calendar" placement="right">
|
||||
<IconButton
|
||||
@ -407,6 +424,7 @@ export function CCGraphNavPanel() {
|
||||
|
||||
const [calendarWizardOpen, setCalendarWizardOpen] = useState(false);
|
||||
const [timetableWizardOpen, setTimetableWizardOpen] = useState(false);
|
||||
const [onboardingWizardOpen, setOnboardingWizardOpen] = useState(false);
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE as string;
|
||||
const activeNodeId = context.node?.type !== 'workspace' ? context.node?.id : undefined;
|
||||
@ -544,6 +562,7 @@ export function CCGraphNavPanel() {
|
||||
schoolStatus,
|
||||
onSetupSchoolCalendar: () => 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}
|
||||
/>
|
||||
|
||||
<SchoolOnboardingWizard
|
||||
open={onboardingWizardOpen}
|
||||
onClose={() => setOnboardingWizardOpen(false)}
|
||||
onComplete={() => {
|
||||
setOnboardingWizardOpen(false);
|
||||
// Reload tree + school status after successful onboarding
|
||||
setTree(null);
|
||||
setSchoolStatus(null);
|
||||
}}
|
||||
apiBase={apiBase}
|
||||
/>
|
||||
</NavPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string> {
|
||||
const addr: Record<string, string> = {};
|
||||
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<string | null>(null);
|
||||
|
||||
// Step 0 — search
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<GaisSchool[]>([]);
|
||||
const [selected, setSelected] = useState<GaisSchool | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Step 1 — confirm/edit
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editWebsite, setEditWebsite] = useState('');
|
||||
const [editHeadteacher, setEditHeadteacher] = useState('');
|
||||
|
||||
// After registration
|
||||
const [registeredSchoolInfo, setRegisteredSchoolInfo] = useState<SchoolInfo | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<Dialog open={open && step < 2} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SchoolIcon fontSize="small" />
|
||||
Set up your school
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Stepper activeStep={step} sx={{ mb: 3, mt: 0.5 }}>
|
||||
{STEPS.map(label => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{step === 0 && (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Search by school name, URN, or postcode"
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
{searching ? <CircularProgress size={16} /> : <SearchIcon fontSize="small" />}
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
{results.length > 0 && (
|
||||
<List dense sx={{ maxHeight: 360, overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
{results.map((school, idx) => (
|
||||
<React.Fragment key={school.urn}>
|
||||
{idx > 0 && <Divider component="li" />}
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={() => handleSelectSchool(school)}>
|
||||
<ListItemText
|
||||
primary={school.name}
|
||||
secondary={
|
||||
<Box component="span" sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.25 }}>
|
||||
<Typography component="span" variant="caption" color="text.secondary">
|
||||
{[school.town, school.county, school.postcode].filter(Boolean).join(', ')}
|
||||
</Typography>
|
||||
{school.phase && (
|
||||
<Chip label={school.phase} size="small" variant="outlined" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 500 }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
{!searching && query.length >= 2 && results.length === 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
No open schools found. Try a different name or postcode.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 1 && selected && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Review and edit your school details before registering.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="School name"
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website"
|
||||
value={editWebsite}
|
||||
onChange={e => setEditWebsite(e.target.value)}
|
||||
size="small"
|
||||
placeholder="https://"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Headteacher"
|
||||
value={editHeadteacher}
|
||||
onChange={e => setEditHeadteacher(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<Box sx={{ p: 1.5, borderRadius: 1, bgcolor: 'action.hover', fontSize: '0.78rem' }}>
|
||||
<Typography variant="caption" display="block" color="text.secondary" sx={{ mb: 0.5 }}>Address from Edubase</Typography>
|
||||
{Object.values(buildAddress(selected)).filter(Boolean).join(', ')}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
URN: {selected.urn} · {selected.phase} · {selected.type}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{step === 0 && (
|
||||
<Button onClick={handleClose} size="small">Cancel</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<Button onClick={() => setStep(0)} size="small">Back</Button>
|
||||
<Button onClick={handleClose} size="small">Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleRegister}
|
||||
disabled={registering || !editName.trim()}
|
||||
startIcon={registering ? <CircularProgress size={14} /> : undefined}
|
||||
>
|
||||
{registering ? 'Registering…' : 'Register school'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Step 2: calendar setup opens as a separate wizard */}
|
||||
{registeredSchoolInfo && (
|
||||
<SchoolCalendarWizard
|
||||
open={step === 2}
|
||||
onClose={() => { reset(); onClose(); }}
|
||||
onComplete={handleCalendarComplete}
|
||||
apiBase={apiBase}
|
||||
schoolInfo={registeredSchoolInfo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user