From 0f5dbd12bf40420c3ff3d53bac479d97821ced26 Mon Sep 17 00:00:00 2001 From: kcar Date: Wed, 27 May 2026 03:59:44 +0100 Subject: [PATCH] =?UTF-8?q?feat(phase-c):=20lesson=20plans=20library=20?= =?UTF-8?q?=E2=80=94=20browse,=20create,=20and=20edit=20lesson=20plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds LessonPlansPage (card grid with search/filter, create dialog) and LessonPlanDetailPage (structured editor with objectives, activities, Bloom taxonomy tags, per-field AI suggest via ✨ button, and auto-save). Routes: /lesson-plans and /lesson-plans/:planId wired into AppRoutes. Nav: Lesson Plans item added to Header menu under Timetable & Classes. Co-Authored-By: Claude Sonnet 4.6 --- src/AppRoutes.tsx | 4 + src/pages/Header.tsx | 10 + src/pages/timetable/LessonPlanDetailPage.tsx | 531 +++++++++++++++++++ src/pages/timetable/LessonPlansPage.tsx | 328 ++++++++++++ src/pages/timetable/index.ts | 2 + 5 files changed, 875 insertions(+) create mode 100644 src/pages/timetable/LessonPlanDetailPage.tsx create mode 100644 src/pages/timetable/LessonPlansPage.tsx diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index ac3e4f3..6dbf04a 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -40,6 +40,8 @@ import { SchoolSettingsPage, ClassDetailPage, StudentLessonsPage, + LessonPlansPage, + LessonPlanDetailPage, } from './pages/timetable'; const FullContextRoutes: React.FC = () => { @@ -134,6 +136,8 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/Header.tsx b/src/pages/Header.tsx index 56e5cb1..ece77e5 100644 --- a/src/pages/Header.tsx +++ b/src/pages/Header.tsx @@ -31,6 +31,7 @@ import Class from '@mui/icons-material/Class'; import Book from '@mui/icons-material/Book'; import Enrollment from '@mui/icons-material/HowToReg'; import Lessons from '@mui/icons-material/EventNote'; +import LessonPlans from '@mui/icons-material/LibraryBooks'; import People from '@mui/icons-material/People'; import SchoolSettings from '@mui/icons-material/Tune'; import { HEADER_HEIGHT } from './Layout'; @@ -279,6 +280,15 @@ const Header: React.FC = () => { secondary="Weekly lesson view" /> , + handleNavigation('/lesson-plans')}> + + + + + , handleNavigation('/student-lessons')}> diff --git a/src/pages/timetable/LessonPlanDetailPage.tsx b/src/pages/timetable/LessonPlanDetailPage.tsx new file mode 100644 index 0000000..6f8fd3d --- /dev/null +++ b/src/pages/timetable/LessonPlanDetailPage.tsx @@ -0,0 +1,531 @@ +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Box, Typography, Button, CircularProgress, Alert, Chip, TextField, + IconButton, Tooltip, Divider, Paper, Select, MenuItem, FormControl, + InputLabel, Switch, FormControlLabel, Dialog, DialogTitle, + DialogContent, DialogActions, +} from '@mui/material'; +import { + ArrowBack, Add, Delete, AutoAwesome, DragIndicator, Save, + LibraryBooks, LinkOff, +} from '@mui/icons-material'; +import { useAuth } from '../../contexts/AuthContext'; + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Objective { + id: string; + text: string; + bloom_level?: string; + order: number; +} + +interface Activity { + id: string; + section: string; + title: string; + description: string; + duration_minutes?: number; + order: number; +} + +interface LessonPlan { + id: string; + title: string; + subject: string | null; + year_group: string | null; + duration_minutes: number | null; + context_notes: string | null; + objectives: Objective[]; + activities: Activity[]; + is_public: boolean; + creator_id: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const BLOOM_LEVELS = ['remember', 'understand', 'apply', 'analyse', 'evaluate', 'create']; +const SECTION_SUGGESTIONS = ['Starter', 'Main', 'Plenary', 'Extension', 'Assessment', 'Discussion']; +const BLOOM_COLORS: Record = { + remember: '#90caf9', understand: '#a5d6a7', apply: '#fff176', + analyse: '#ffcc80', evaluate: '#ef9a9a', create: '#ce93d8', +}; + +// ─── AI Suggest button ──────────────────────────────────────────────────────── + +function SuggestButton({ onClick, loading }: { onClick: () => void; loading: boolean }) { + return ( + + + + {loading ? : } + + + + ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +const LessonPlanDetailPage: React.FC = () => { + const { planId } = useParams<{ planId: string }>(); + const navigate = useNavigate(); + const { accessToken } = useAuth(); + + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [suggestingField, setSuggestingField] = useState(null); + + // Local editable state (mirrors plan) + const [title, setTitle] = useState(''); + const [subject, setSubject] = useState(''); + const [yearGroup, setYearGroup] = useState(''); + const [duration, setDuration] = useState(''); + const [contextNotes, setContextNotes] = useState(''); + const [isPublic, setIsPublic] = useState(false); + const [objectives, setObjectives] = useState([]); + const [activities, setActivities] = useState([]); + + const autoSaveTimer = useRef | null>(null); + + const load = useCallback(async () => { + if (!accessToken || !planId) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/lessons/plans/${planId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const data = await res.json(); + if (data.id) { + setPlan(data); + setTitle(data.title || ''); + setSubject(data.subject || ''); + setYearGroup(data.year_group || ''); + setDuration(data.duration_minutes?.toString() || ''); + setContextNotes(data.context_notes || ''); + setIsPublic(data.is_public || false); + setObjectives(data.objectives || []); + setActivities(data.activities || []); + } else { + setError(data.detail || 'Plan not found'); + } + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }, [accessToken, planId]); + + useEffect(() => { load(); }, [load]); + + // ── Save ────────────────────────────────────────────────────────────────── + + const save = useCallback(async ( + overrides?: Partial<{ title: string; subject: string; year_group: string; duration_minutes: number | null; context_notes: string; is_public: boolean; objectives: Objective[]; activities: Activity[] }> + ) => { + if (!accessToken || !planId) return; + setSaving(true); + setSaved(false); + try { + await fetch(`${API_BASE}/lessons/plans/${planId}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + subject: subject || null, + year_group: yearGroup || null, + duration_minutes: duration ? parseInt(duration, 10) : null, + context_notes: contextNotes || null, + is_public: isPublic, + objectives, + activities, + ...overrides, + }), + }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }, [accessToken, planId, title, subject, yearGroup, duration, contextNotes, isPublic, objectives, activities]); + + const scheduleAutoSave = useCallback(() => { + if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); + autoSaveTimer.current = setTimeout(() => save(), 1500); + }, [save]); + + // ── Objectives ──────────────────────────────────────────────────────────── + + const addObjective = () => { + const next: Objective = { id: crypto.randomUUID(), text: '', bloom_level: undefined, order: objectives.length }; + setObjectives(prev => [...prev, next]); + }; + + const updateObjective = (id: string, patch: Partial) => { + setObjectives(prev => prev.map(o => o.id === id ? { ...o, ...patch } : o)); + scheduleAutoSave(); + }; + + const removeObjective = (id: string) => { + setObjectives(prev => prev.filter(o => o.id !== id).map((o, i) => ({ ...o, order: i }))); + scheduleAutoSave(); + }; + + // ── Activities ──────────────────────────────────────────────────────────── + + const addActivity = () => { + const next: Activity = { + id: crypto.randomUUID(), section: 'Main', title: '', description: '', order: activities.length, + }; + setActivities(prev => [...prev, next]); + }; + + const updateActivity = (id: string, patch: Partial) => { + setActivities(prev => prev.map(a => a.id === id ? { ...a, ...patch } : a)); + scheduleAutoSave(); + }; + + const removeActivity = (id: string) => { + setActivities(prev => prev.filter(a => a.id !== id).map((a, i) => ({ ...a, order: i }))); + scheduleAutoSave(); + }; + + // ── AI Suggest ──────────────────────────────────────────────────────────── + + const suggest = async (field: string, targetId?: string) => { + if (!accessToken || !planId) return; + const key = targetId ? `${field}_${targetId}` : field; + setSuggestingField(key); + try { + const body: Record = { + field, + context: contextNotes, + objective_texts: objectives.map(o => o.text).filter(Boolean), + }; + if (field === 'activity_description' && targetId) { + const act = activities.find(a => a.id === targetId); + if (act) body.activity_section = act.section; + } + const res = await fetch(`${API_BASE}/lessons/plans/${planId}/suggest`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (data.suggestion) { + if (field === 'objectives') { + addObjective(); + setObjectives(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { ...updated[updated.length - 1], text: data.suggestion }; + return updated; + }); + } else if (field === 'activity_description' && targetId) { + updateActivity(targetId, { description: data.suggestion }); + } else if (field === 'title') { + setTitle(data.suggestion); + scheduleAutoSave(); + } + } + } catch (e: any) { + setError('AI suggest failed: ' + e.message); + } finally { + setSuggestingField(null); + } + }; + + // ── Render ──────────────────────────────────────────────────────────────── + + if (loading) { + return ( + + + + ); + } + + if (error && !plan) { + return ( + + {error} + + + ); + } + + return ( + + {/* Header */} + + + + {saved && Saved} + + + + + {error && ( + setError(null)} sx={{ mb: 2 }}>{error} + )} + + {/* Title row */} + + { setTitle(e.target.value); scheduleAutoSave(); }} + variant="standard" + fullWidth + inputProps={{ style: { fontSize: '1.5rem', fontWeight: 700 } }} + placeholder="Lesson title…" + /> + suggest('title')} + loading={suggestingField === 'title'} + /> + + + {/* Metadata row */} + + { setSubject(e.target.value); scheduleAutoSave(); }} + size="small" + sx={{ width: 200 }} + /> + { setYearGroup(e.target.value); scheduleAutoSave(); }} + size="small" + sx={{ width: 120 }} + /> + { setDuration(e.target.value); scheduleAutoSave(); }} + size="small" + type="number" + inputProps={{ min: 1, max: 300 }} + sx={{ width: 140 }} + /> + { setIsPublic(e.target.checked); scheduleAutoSave(); }} + /> + } + label={Public} + sx={{ ml: 0.5 }} + /> + + + {/* Context notes */} + + + Context / notes + + { setContextNotes(e.target.value); scheduleAutoSave(); }} + multiline + minRows={2} + fullWidth + size="small" + placeholder="Class context, prior knowledge, SEN considerations, links to curriculum…" + /> + + + + + {/* Objectives */} + + + + Learning objectives + + + suggest('objectives')} + loading={suggestingField === 'objectives'} + /> + + + + + + + + + {objectives.length === 0 ? ( + + No objectives yet — add one or use ✨ to suggest. + + ) : ( + + {objectives.map((obj, idx) => ( + + + + updateObjective(obj.id, { text: e.target.value })} + fullWidth + size="small" + multiline + placeholder={`Objective ${idx + 1}…`} + variant="standard" + /> + + + + + removeObjective(obj.id)} sx={{ flexShrink: 0, p: 0.25 }}> + + + + + + ))} + + )} + + + + + {/* Activities */} + + + + Activities + + + + + + + + + {activities.length === 0 ? ( + + No activities yet — add one to structure your lesson. + + ) : ( + + {activities.map((act, idx) => ( + + {/* Activity header */} + + + + {/* Section selector */} + updateActivity(act.id, { section: e.target.value })} + size="small" + label="Section" + sx={{ width: 140, flexShrink: 0 }} + select={false} + inputProps={{ list: `section-suggestions-${act.id}` }} + placeholder="Starter / Main…" + /> + + {SECTION_SUGGESTIONS.map(s => + + updateActivity(act.id, { title: e.target.value })} + size="small" + label="Activity title" + fullWidth + placeholder={`Activity ${idx + 1}…`} + /> + updateActivity(act.id, { duration_minutes: e.target.value ? parseInt(e.target.value, 10) : undefined })} + size="small" + label="Min" + type="number" + inputProps={{ min: 1 }} + sx={{ width: 72, flexShrink: 0 }} + /> + + removeActivity(act.id)} sx={{ flexShrink: 0, p: 0.25 }}> + + + + + + {/* Description */} + + updateActivity(act.id, { description: e.target.value })} + size="small" + multiline + minRows={2} + fullWidth + placeholder="Describe what the teacher and pupils do…" + /> + suggest('activity_description', act.id)} + loading={suggestingField === `activity_description_${act.id}`} + /> + + + ))} + + )} + + + ); +}; + +export default LessonPlanDetailPage; diff --git a/src/pages/timetable/LessonPlansPage.tsx b/src/pages/timetable/LessonPlansPage.tsx new file mode 100644 index 0000000..5855eea --- /dev/null +++ b/src/pages/timetable/LessonPlansPage.tsx @@ -0,0 +1,328 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, Typography, Button, CircularProgress, Alert, Chip, Card, + CardContent, CardActions, IconButton, Tooltip, TextField, + InputAdornment, MenuItem, Select, FormControl, InputLabel, + Dialog, DialogTitle, DialogContent, DialogActions, +} from '@mui/material'; +import { Add, Search, LibraryBooks, Edit, Delete, FilterList } from '@mui/icons-material'; +import { useAuth } from '../../contexts/AuthContext'; + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface PlanSummary { + id: string; + title: string; + subject: string | null; + year_group: string | null; + duration_minutes: number | null; + objectives: { id: string; text: string; bloom_level?: string }[]; + activities: { id: string; section: string; title: string }[]; + is_public: boolean; + created_at: string; + updated_at: string; + creator_name?: string; +} + +// ─── Create plan dialog ─────────────────────────────────────────────────────── + +interface CreateDialogProps { + open: boolean; + onClose: () => void; + onCreate: (data: { title: string; subject?: string; year_group?: string }) => Promise; +} + +function CreatePlanDialog({ open, onClose, onCreate }: CreateDialogProps) { + const [title, setTitle] = useState(''); + const [subject, setSubject] = useState(''); + const [yearGroup, setYearGroup] = useState(''); + const [saving, setSaving] = useState(false); + + const reset = () => { setTitle(''); setSubject(''); setYearGroup(''); }; + + const handleCreate = async () => { + if (!title.trim()) return; + setSaving(true); + await onCreate({ title: title.trim(), subject: subject || undefined, year_group: yearGroup || undefined }); + setSaving(false); + reset(); + onClose(); + }; + + const handleClose = () => { reset(); onClose(); }; + + return ( + + New Lesson Plan + + + setTitle(e.target.value)} + size="small" + fullWidth + autoFocus + required + placeholder="e.g. Introduction to Photosynthesis" + /> + + setSubject(e.target.value)} + size="small" + fullWidth + placeholder="e.g. Biology" + /> + setYearGroup(e.target.value)} + size="small" + sx={{ width: 140 }} + placeholder="e.g. 9" + /> + + + + + + + + + ); +} + +// ─── Plan card ──────────────────────────────────────────────────────────────── + +function PlanCard({ plan, onOpen, onDelete }: { + plan: PlanSummary; + onOpen: (id: string) => void; + onDelete: (id: string) => void; +}) { + const objCount = plan.objectives?.length ?? 0; + const actCount = plan.activities?.length ?? 0; + + return ( + onOpen(plan.id)} + > + + + {plan.title} + + + {plan.subject && ( + + )} + {plan.year_group && ( + + )} + {plan.duration_minutes && ( + + )} + {plan.is_public && ( + + )} + + + {objCount} objective{objCount !== 1 ? 's' : ''} · {actCount} activit{actCount !== 1 ? 'ies' : 'y'} + + + e.stopPropagation()}> + + onOpen(plan.id)}> + + + + + onDelete(plan.id)}> + + + + + + ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +const LessonPlansPage: React.FC = () => { + const { accessToken } = useAuth(); + const navigate = useNavigate(); + + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [search, setSearch] = useState(''); + const [filterSubject, setFilterSubject] = useState(''); + + const load = useCallback(async () => { + if (!accessToken) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/lessons/plans`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const data = await res.json(); + if (Array.isArray(data)) setPlans(data); + else setError(data.detail || 'Failed to load plans'); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }, [accessToken]); + + useEffect(() => { load(); }, [load]); + + const handleCreate = async (body: { title: string; subject?: string; year_group?: string }) => { + const res = await fetch(`${API_BASE}/lessons/plans`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (data.id) { + navigate(`/lesson-plans/${data.id}`); + } else { + load(); + } + }; + + const handleDelete = async (id: string) => { + if (!window.confirm('Delete this lesson plan?')) return; + await fetch(`${API_BASE}/lessons/plans/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + setPlans(prev => prev.filter(p => p.id !== id)); + }; + + const subjects = Array.from(new Set(plans.map(p => p.subject).filter(Boolean) as string[])).sort(); + + const filtered = plans.filter(p => { + const matchSearch = !search || p.title.toLowerCase().includes(search.toLowerCase()) + || (p.subject || '').toLowerCase().includes(search.toLowerCase()); + const matchSubject = !filterSubject || p.subject === filterSubject; + return matchSearch && matchSubject; + }); + + return ( + + {/* Header */} + + + + + Lesson Plans + + {plans.length} plan{plans.length !== 1 ? 's' : ''} + + + + + + + {/* Filters */} + + setSearch(e.target.value)} + InputProps={{ + startAdornment: , + }} + sx={{ width: 280 }} + /> + {subjects.length > 0 && ( + + Subject + + + )} + {(search || filterSubject) && ( + + )} + + + {error && {error}} + + {loading ? ( + + + + ) : filtered.length === 0 ? ( + + + + {plans.length === 0 ? 'No lesson plans yet.' : 'No plans match your filters.'} + + {plans.length === 0 && ( + + )} + + ) : ( + + {filtered.map(plan => ( + navigate(`/lesson-plans/${id}`)} + onDelete={handleDelete} + /> + ))} + + )} + + setCreateOpen(false)} + onCreate={handleCreate} + /> + + ); +}; + +export default LessonPlansPage; diff --git a/src/pages/timetable/index.ts b/src/pages/timetable/index.ts index 5c6d5e0..c9bffe3 100644 --- a/src/pages/timetable/index.ts +++ b/src/pages/timetable/index.ts @@ -15,3 +15,5 @@ export { default as ClassDetailPage } from './ClassDetailPage'; export { default as StudentLessonsPage } from './StudentLessonsPage'; export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage'; +export { default as LessonPlansPage } from './LessonPlansPage'; +export { default as LessonPlanDetailPage } from './LessonPlanDetailPage';