feat(timetable): add assign lesson plan dialog to TaughtLessonsPage

Each lesson card gets a LibraryAdd button that opens a searchable
plan picker. Falls back gracefully to empty state when no plans exist,
with a link to /lesson-plans to create one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-27 11:25:55 +01:00
parent 9438e17f88
commit 510bef02b6

View File

@ -1,12 +1,14 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
Dialog, DialogTitle, DialogContent, DialogActions,
TextField, Select, MenuItem, FormControl, InputLabel,
Divider, IconButton, Tooltip,
Divider, IconButton, Tooltip, List, ListItemButton, ListItemText,
InputAdornment,
} from '@mui/material';
import {
ChevronLeft, ChevronRight, Today, EditNote,
ChevronLeft, ChevronRight, Today, EditNote, LibraryAdd, Search, OpenInNew,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
@ -152,9 +154,10 @@ function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
interface LessonCardProps {
lesson: Lesson;
onEdit: (l: Lesson) => void;
onAssignPlan: (l: Lesson) => void;
}
function LessonCard({ lesson, onEdit }: LessonCardProps) {
function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
return (
<Box
sx={{
@ -174,11 +177,18 @@ function LessonCard({ lesson, onEdit }: LessonCardProps) {
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{lesson.class_name || lesson.period_code}
</Typography>
<Tooltip title="Edit lesson">
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ ml: 0.5, p: 0.25 }}>
<EditNote fontSize="inherit" />
</IconButton>
</Tooltip>
<Box sx={{ display: 'flex', gap: 0 }}>
<Tooltip title="Assign lesson plan">
<IconButton size="small" onClick={() => onAssignPlan(lesson)} sx={{ p: 0.25 }}>
<LibraryAdd fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Edit lesson">
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ p: 0.25 }}>
<EditNote fontSize="inherit" />
</IconButton>
</Tooltip>
</Box>
</Box>
{lesson.subject && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
@ -212,6 +222,7 @@ function LessonCard({ lesson, onEdit }: LessonCardProps) {
const TaughtLessonsPage: React.FC = () => {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
const [days, setDays] = useState<DayEntry[]>([]);
const [loading, setLoading] = useState(false);
@ -219,6 +230,11 @@ const TaughtLessonsPage: React.FC = () => {
const [materializing, setMaterializing] = useState(false);
const [materializeResult, setMaterializeResult] = useState<string | null>(null);
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
const [assigningLesson, setAssigningLesson] = useState<Lesson | null>(null);
const [plans, setPlans] = useState<{ id: string; title: string; subject: string | null }[]>([]);
const [planSearch, setPlanSearch] = useState('');
const [assignSaving, setAssignSaving] = useState(false);
const [assignResult, setAssignResult] = useState<string | null>(null);
const loadLessons = useCallback(async (ws: string) => {
if (!accessToken) return;
@ -281,10 +297,51 @@ const TaughtLessonsPage: React.FC = () => {
loadLessons(weekStart);
};
const openAssignDialog = async (lesson: Lesson) => {
setAssigningLesson(lesson);
setPlanSearch('');
setAssignResult(null);
if (plans.length === 0) {
try {
const res = await fetch(`${API_BASE}/lessons/plans`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await res.json();
if (Array.isArray(data)) setPlans(data);
} catch { /* silently ignore */ }
}
};
const handleAssignPlan = async (planId: string) => {
if (!assigningLesson) return;
setAssignSaving(true);
try {
const res = await fetch(`${API_BASE}/lessons/plans/${planId}/deliver`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ taught_lesson_id: assigningLesson.id }),
});
const data = await res.json();
if (data.id) {
setAssignResult('Plan linked ✓');
setTimeout(() => { setAssigningLesson(null); setAssignResult(null); }, 1200);
} else {
setAssignResult(data.detail || 'Failed to link plan');
}
} catch (e: any) {
setAssignResult(e.message);
} finally {
setAssignSaving(false);
}
};
const filteredPlans = plans.filter(p =>
!planSearch || p.title.toLowerCase().includes(planSearch.toLowerCase())
|| (p.subject || '').toLowerCase().includes(planSearch.toLowerCase())
);
const totalLessons = days.reduce((sum, d) => sum + d.lessons.length, 0);
// Format week label
const weekDates = days.filter(d => d.lessons.length > 0 || true).map(d => d.date);
const weekLabel = days.length > 0
? `${formatDate(days[0].date)} ${formatDate(days[days.length - 1].date)}`
: '';
@ -369,6 +426,7 @@ const TaughtLessonsPage: React.FC = () => {
key={lesson.id}
lesson={lesson}
onEdit={setEditingLesson}
onAssignPlan={openAssignDialog}
/>
))
)}
@ -382,6 +440,71 @@ const TaughtLessonsPage: React.FC = () => {
onClose={() => setEditingLesson(null)}
onSave={handleSaveLesson}
/>
{/* Assign Plan dialog */}
<Dialog open={!!assigningLesson} onClose={() => setAssigningLesson(null)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ pb: 1 }}>
<Typography variant="h6">Assign Lesson Plan</Typography>
{assigningLesson && (
<Typography variant="caption" color="text.secondary">
{assigningLesson.class_name || assigningLesson.period_code} · {assigningLesson.date}
</Typography>
)}
</DialogTitle>
<DialogContent sx={{ pt: 1 }}>
{assignResult ? (
<Alert severity={assignResult.includes('✓') ? 'success' : 'error'} sx={{ mb: 1 }}>
{assignResult}
</Alert>
) : null}
<TextField
size="small"
fullWidth
placeholder="Search plans…"
value={planSearch}
onChange={e => setPlanSearch(e.target.value)}
InputProps={{
startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment>,
}}
sx={{ mb: 1 }}
/>
{plans.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
No lesson plans yet.{' '}
<Button size="small" endIcon={<OpenInNew fontSize="small" />}
onClick={() => { setAssigningLesson(null); navigate('/lesson-plans'); }}>
Create one
</Button>
</Typography>
) : (
<List dense disablePadding sx={{ maxHeight: 300, overflow: 'auto' }}>
{filteredPlans.map(p => (
<ListItemButton
key={p.id}
onClick={() => handleAssignPlan(p.id)}
disabled={assignSaving}
sx={{ borderRadius: 1, mb: 0.25 }}
>
<ListItemText
primary={p.title}
secondary={p.subject || undefined}
primaryTypographyProps={{ variant: 'body2' }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
{assignSaving && <CircularProgress size={16} sx={{ ml: 1 }} />}
</ListItemButton>
))}
</List>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setAssigningLesson(null)}>Close</Button>
<Button size="small" endIcon={<OpenInNew fontSize="small" />}
onClick={() => { setAssigningLesson(null); navigate('/lesson-plans'); }}>
Manage Plans
</Button>
</DialogActions>
</Dialog>
</Box>
);
};