feat(phase-c): lesson plans library — browse, create, and edit lesson plans
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 <noreply@anthropic.com>
This commit is contained in:
parent
6ac5ab7b5c
commit
0f5dbd12bf
@ -40,6 +40,8 @@ import {
|
|||||||
SchoolSettingsPage,
|
SchoolSettingsPage,
|
||||||
ClassDetailPage,
|
ClassDetailPage,
|
||||||
StudentLessonsPage,
|
StudentLessonsPage,
|
||||||
|
LessonPlansPage,
|
||||||
|
LessonPlanDetailPage,
|
||||||
} from './pages/timetable';
|
} from './pages/timetable';
|
||||||
|
|
||||||
const FullContextRoutes: React.FC = () => {
|
const FullContextRoutes: React.FC = () => {
|
||||||
@ -134,6 +136,8 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||||
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||||
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
||||||
|
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
||||||
|
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
||||||
<Route path="/enrollment-requests" element={<EnrollmentRequestsPage />} />
|
<Route path="/enrollment-requests" element={<EnrollmentRequestsPage />} />
|
||||||
<Route path="/lessons/:lessonId" element={<LessonPage />} />
|
<Route path="/lessons/:lessonId" element={<LessonPage />} />
|
||||||
<Route path="/my-lessons" element={<TaughtLessonsPage />} />
|
<Route path="/my-lessons" element={<TaughtLessonsPage />} />
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import Class from '@mui/icons-material/Class';
|
|||||||
import Book from '@mui/icons-material/Book';
|
import Book from '@mui/icons-material/Book';
|
||||||
import Enrollment from '@mui/icons-material/HowToReg';
|
import Enrollment from '@mui/icons-material/HowToReg';
|
||||||
import Lessons from '@mui/icons-material/EventNote';
|
import Lessons from '@mui/icons-material/EventNote';
|
||||||
|
import LessonPlans from '@mui/icons-material/LibraryBooks';
|
||||||
import People from '@mui/icons-material/People';
|
import People from '@mui/icons-material/People';
|
||||||
import SchoolSettings from '@mui/icons-material/Tune';
|
import SchoolSettings from '@mui/icons-material/Tune';
|
||||||
import { HEADER_HEIGHT } from './Layout';
|
import { HEADER_HEIGHT } from './Layout';
|
||||||
@ -279,6 +280,15 @@ const Header: React.FC = () => {
|
|||||||
secondary="Weekly lesson view"
|
secondary="Weekly lesson view"
|
||||||
/>
|
/>
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
|
<MenuItem key="lesson-plans" onClick={() => handleNavigation('/lesson-plans')}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LessonPlans />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Lesson Plans"
|
||||||
|
secondary="Plan and manage lessons"
|
||||||
|
/>
|
||||||
|
</MenuItem>,
|
||||||
<MenuItem key="student-lessons" onClick={() => handleNavigation('/student-lessons')}>
|
<MenuItem key="student-lessons" onClick={() => handleNavigation('/student-lessons')}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Student />
|
<Student />
|
||||||
|
|||||||
531
src/pages/timetable/LessonPlanDetailPage.tsx
Normal file
531
src/pages/timetable/LessonPlanDetailPage.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
remember: '#90caf9', understand: '#a5d6a7', apply: '#fff176',
|
||||||
|
analyse: '#ffcc80', evaluate: '#ef9a9a', create: '#ce93d8',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── AI Suggest button ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SuggestButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<Tooltip title="AI suggest">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={onClick} disabled={loading} color="primary" sx={{ p: 0.5 }}>
|
||||||
|
{loading ? <CircularProgress size={16} /> : <AutoAwesome fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LessonPlanDetailPage: React.FC = () => {
|
||||||
|
const { planId } = useParams<{ planId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
|
||||||
|
const [plan, setPlan] = useState<LessonPlan | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [suggestingField, setSuggestingField] = useState<string | null>(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<Objective[]>([]);
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
|
||||||
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | 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<Objective>) => {
|
||||||
|
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<Activity>) => {
|
||||||
|
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<string, any> = {
|
||||||
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !plan) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/lesson-plans')}>
|
||||||
|
Back to Plans
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 860, mx: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Button size="small" startIcon={<ArrowBack />} onClick={() => navigate('/lesson-plans')}>
|
||||||
|
Lesson Plans
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{saved && <Typography variant="caption" color="success.main">Saved</Typography>}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={saving ? <CircularProgress size={14} /> : <Save />}
|
||||||
|
onClick={() => save()}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title row */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
|
||||||
|
<TextField
|
||||||
|
value={title}
|
||||||
|
onChange={e => { setTitle(e.target.value); scheduleAutoSave(); }}
|
||||||
|
variant="standard"
|
||||||
|
fullWidth
|
||||||
|
inputProps={{ style: { fontSize: '1.5rem', fontWeight: 700 } }}
|
||||||
|
placeholder="Lesson title…"
|
||||||
|
/>
|
||||||
|
<SuggestButton
|
||||||
|
onClick={() => suggest('title')}
|
||||||
|
loading={suggestingField === 'title'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 3, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
label="Subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={e => { setSubject(e.target.value); scheduleAutoSave(); }}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Year group"
|
||||||
|
value={yearGroup}
|
||||||
|
onChange={e => { setYearGroup(e.target.value); scheduleAutoSave(); }}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Duration (min)"
|
||||||
|
value={duration}
|
||||||
|
onChange={e => { setDuration(e.target.value); scheduleAutoSave(); }}
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 1, max: 300 }}
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={isPublic}
|
||||||
|
onChange={e => { setIsPublic(e.target.checked); scheduleAutoSave(); }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="caption">Public</Typography>}
|
||||||
|
sx={{ ml: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Context notes */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 0.75 }}>
|
||||||
|
Context / notes
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
value={contextNotes}
|
||||||
|
onChange={e => { setContextNotes(e.target.value); scheduleAutoSave(); }}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Class context, prior knowledge, SEN considerations, links to curriculum…"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Objectives */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700}>
|
||||||
|
Learning objectives
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<SuggestButton
|
||||||
|
onClick={() => suggest('objectives')}
|
||||||
|
loading={suggestingField === 'objectives'}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Add objective">
|
||||||
|
<IconButton size="small" onClick={addObjective}>
|
||||||
|
<Add fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{objectives.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1.5, textAlign: 'center' }}>
|
||||||
|
No objectives yet — add one or use ✨ to suggest.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{objectives.map((obj, idx) => (
|
||||||
|
<Paper key={obj.id} variant="outlined" sx={{ p: 1.25 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<DragIndicator sx={{ color: 'text.disabled', mt: 0.5, flexShrink: 0 }} fontSize="small" />
|
||||||
|
<TextField
|
||||||
|
value={obj.text}
|
||||||
|
onChange={e => updateObjective(obj.id, { text: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
placeholder={`Objective ${idx + 1}…`}
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 120, flexShrink: 0 }}>
|
||||||
|
<Select
|
||||||
|
value={obj.bloom_level || ''}
|
||||||
|
onChange={e => updateObjective(obj.id, { bloom_level: e.target.value || undefined })}
|
||||||
|
displayEmpty
|
||||||
|
variant="standard"
|
||||||
|
renderValue={v => v ? (
|
||||||
|
<Chip
|
||||||
|
label={v as string}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem', bgcolor: BLOOM_COLORS[v as string] || 'grey.200' }}
|
||||||
|
/>
|
||||||
|
) : <Typography variant="caption" color="text.disabled">Bloom level</Typography>}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>None</em></MenuItem>
|
||||||
|
{BLOOM_LEVELS.map(l => (
|
||||||
|
<MenuItem key={l} value={l}>
|
||||||
|
<Chip
|
||||||
|
label={l}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem', bgcolor: BLOOM_COLORS[l] || 'grey.200' }}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<IconButton size="small" color="error" onClick={() => removeObjective(obj.id)} sx={{ flexShrink: 0, p: 0.25 }}>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Activities */}
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700}>
|
||||||
|
Activities
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Add activity">
|
||||||
|
<IconButton size="small" onClick={addActivity}>
|
||||||
|
<Add fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1.5, textAlign: 'center' }}>
|
||||||
|
No activities yet — add one to structure your lesson.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{activities.map((act, idx) => (
|
||||||
|
<Paper key={act.id} variant="outlined" sx={{ p: 1.5 }}>
|
||||||
|
{/* Activity header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<DragIndicator sx={{ color: 'text.disabled', flexShrink: 0 }} fontSize="small" />
|
||||||
|
|
||||||
|
{/* Section selector */}
|
||||||
|
<TextField
|
||||||
|
value={act.section}
|
||||||
|
onChange={e => 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…"
|
||||||
|
/>
|
||||||
|
<datalist id={`section-suggestions-${act.id}`}>
|
||||||
|
{SECTION_SUGGESTIONS.map(s => <option key={s} value={s} />)}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
value={act.title}
|
||||||
|
onChange={e => updateActivity(act.id, { title: e.target.value })}
|
||||||
|
size="small"
|
||||||
|
label="Activity title"
|
||||||
|
fullWidth
|
||||||
|
placeholder={`Activity ${idx + 1}…`}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
value={act.duration_minutes?.toString() || ''}
|
||||||
|
onChange={e => 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 }}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<IconButton size="small" color="error" onClick={() => removeActivity(act.id)} sx={{ flexShrink: 0, p: 0.25 }}>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, pl: 3.5 }}>
|
||||||
|
<TextField
|
||||||
|
value={act.description}
|
||||||
|
onChange={e => updateActivity(act.id, { description: e.target.value })}
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
fullWidth
|
||||||
|
placeholder="Describe what the teacher and pupils do…"
|
||||||
|
/>
|
||||||
|
<SuggestButton
|
||||||
|
onClick={() => suggest('activity_description', act.id)}
|
||||||
|
loading={suggestingField === `activity_description_${act.id}`}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonPlanDetailPage;
|
||||||
328
src/pages/timetable/LessonPlansPage.tsx
Normal file
328
src/pages/timetable/LessonPlansPage.tsx
Normal file
@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>New Lesson Plan</DialogTitle>
|
||||||
|
<DialogContent sx={{ pt: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
placeholder="e.g. Introduction to Photosynthesis"
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={e => setSubject(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="e.g. Biology"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Year group"
|
||||||
|
value={yearGroup}
|
||||||
|
onChange={e => setYearGroup(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
placeholder="e.g. 9"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={handleClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!title.trim() || saving}
|
||||||
|
startIcon={saving ? <CircularProgress size={16} /> : <Add />}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<Card
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
display: 'flex', flexDirection: 'column', cursor: 'pointer',
|
||||||
|
'&:hover': { borderColor: 'primary.main', boxShadow: 1 },
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
|
}}
|
||||||
|
onClick={() => onOpen(plan.id)}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ flex: 1, pb: 1 }}>
|
||||||
|
<Typography variant="body1" fontWeight={700} sx={{ mb: 0.5, lineHeight: 1.3 }}>
|
||||||
|
{plan.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mb: 1 }}>
|
||||||
|
{plan.subject && (
|
||||||
|
<Chip label={plan.subject} size="small" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
{plan.year_group && (
|
||||||
|
<Chip label={`Y${plan.year_group}`} size="small" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
{plan.duration_minutes && (
|
||||||
|
<Chip label={`${plan.duration_minutes}min`} size="small" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
{plan.is_public && (
|
||||||
|
<Chip label="Public" size="small" color="info" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{objCount} objective{objCount !== 1 ? 's' : ''} · {actCount} activit{actCount !== 1 ? 'ies' : 'y'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ pt: 0, px: 1.5, pb: 1.5, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<Tooltip title="Edit plan">
|
||||||
|
<IconButton size="small" onClick={() => onOpen(plan.id)}>
|
||||||
|
<Edit fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete plan">
|
||||||
|
<IconButton size="small" color="error" onClick={() => onDelete(plan.id)}>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LessonPlansPage: React.FC = () => {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<LibraryBooks sx={{ color: 'primary.main', fontSize: 32 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={700}>Lesson Plans</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{plans.length} plan{plans.length !== 1 ? 's' : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
New Plan
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search plans…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment>,
|
||||||
|
}}
|
||||||
|
sx={{ width: 280 }}
|
||||||
|
/>
|
||||||
|
{subjects.length > 0 && (
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Subject</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Subject"
|
||||||
|
value={filterSubject}
|
||||||
|
onChange={e => setFilterSubject(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All subjects</MenuItem>
|
||||||
|
{subjects.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
{(search || filterSubject) && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<FilterList />}
|
||||||
|
onClick={() => { setSearch(''); setFilterSubject(''); }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8, color: 'text.secondary' }}>
|
||||||
|
<LibraryBooks sx={{ fontSize: 56, opacity: 0.2, mb: 1 }} />
|
||||||
|
<Typography variant="body1">
|
||||||
|
{plans.length === 0 ? 'No lesson plans yet.' : 'No plans match your filters.'}
|
||||||
|
</Typography>
|
||||||
|
{plans.length === 0 && (
|
||||||
|
<Button sx={{ mt: 2 }} variant="outlined" startIcon={<Add />} onClick={() => setCreateOpen(true)}>
|
||||||
|
Create your first plan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2 }}>
|
||||||
|
{filtered.map(plan => (
|
||||||
|
<PlanCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
onOpen={id => navigate(`/lesson-plans/${id}`)}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreatePlanDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonPlansPage;
|
||||||
@ -15,3 +15,5 @@ export { default as ClassDetailPage } from './ClassDetailPage';
|
|||||||
export { default as StudentLessonsPage } from './StudentLessonsPage';
|
export { default as StudentLessonsPage } from './StudentLessonsPage';
|
||||||
|
|
||||||
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';
|
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';
|
||||||
|
export { default as LessonPlansPage } from './LessonPlansPage';
|
||||||
|
export { default as LessonPlanDetailPage } from './LessonPlanDetailPage';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user