331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
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_BASE || import.meta.env.VITE_API_URL || '/api';
|
|
|
|
// ─── 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 }[];
|
|
homework: string | null;
|
|
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'}
|
|
{plan.homework ? ' · homework set' : ''}
|
|
</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;
|