Compare commits

...

2 Commits

Author SHA1 Message Date
510bef02b6 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>
2026-05-27 11:25:55 +01:00
9438e17f88 feat(nav): add launch buttons for My Timetable and My Classes sections
Add useNavigate + Launch icon to TreeItem; timetable section shows a
launch button routing to /my-lessons, classes section routes to /my-classes.
Both buttons only appear when the section status is populated.
Exclude classes section from the unlinked/pending icon fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:22:26 +01:00
2 changed files with 163 additions and 12 deletions

View File

@ -1,12 +1,14 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Box, Typography, Button, CircularProgress, Alert, Chip, Box, Typography, Button, CircularProgress, Alert, Chip,
Dialog, DialogTitle, DialogContent, DialogActions, Dialog, DialogTitle, DialogContent, DialogActions,
TextField, Select, MenuItem, FormControl, InputLabel, TextField, Select, MenuItem, FormControl, InputLabel,
Divider, IconButton, Tooltip, Divider, IconButton, Tooltip, List, ListItemButton, ListItemText,
InputAdornment,
} from '@mui/material'; } from '@mui/material';
import { import {
ChevronLeft, ChevronRight, Today, EditNote, ChevronLeft, ChevronRight, Today, EditNote, LibraryAdd, Search, OpenInNew,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@ -152,9 +154,10 @@ function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
interface LessonCardProps { interface LessonCardProps {
lesson: Lesson; lesson: Lesson;
onEdit: (l: Lesson) => void; onEdit: (l: Lesson) => void;
onAssignPlan: (l: Lesson) => void;
} }
function LessonCard({ lesson, onEdit }: LessonCardProps) { function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
return ( return (
<Box <Box
sx={{ sx={{
@ -174,12 +177,19 @@ function LessonCard({ lesson, onEdit }: LessonCardProps) {
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}> <Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{lesson.class_name || lesson.period_code} {lesson.class_name || lesson.period_code}
</Typography> </Typography>
<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"> <Tooltip title="Edit lesson">
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ ml: 0.5, p: 0.25 }}> <IconButton size="small" onClick={() => onEdit(lesson)} sx={{ p: 0.25 }}>
<EditNote fontSize="inherit" /> <EditNote fontSize="inherit" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
</Box>
{lesson.subject && ( {lesson.subject && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}> <Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''} {lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
@ -212,6 +222,7 @@ function LessonCard({ lesson, onEdit }: LessonCardProps) {
const TaughtLessonsPage: React.FC = () => { const TaughtLessonsPage: React.FC = () => {
const { accessToken } = useAuth(); const { accessToken } = useAuth();
const navigate = useNavigate();
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date())); const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
const [days, setDays] = useState<DayEntry[]>([]); const [days, setDays] = useState<DayEntry[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -219,6 +230,11 @@ const TaughtLessonsPage: React.FC = () => {
const [materializing, setMaterializing] = useState(false); const [materializing, setMaterializing] = useState(false);
const [materializeResult, setMaterializeResult] = useState<string | null>(null); const [materializeResult, setMaterializeResult] = useState<string | null>(null);
const [editingLesson, setEditingLesson] = useState<Lesson | 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) => { const loadLessons = useCallback(async (ws: string) => {
if (!accessToken) return; if (!accessToken) return;
@ -281,10 +297,51 @@ const TaughtLessonsPage: React.FC = () => {
loadLessons(weekStart); 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); 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 const weekLabel = days.length > 0
? `${formatDate(days[0].date)} ${formatDate(days[days.length - 1].date)}` ? `${formatDate(days[0].date)} ${formatDate(days[days.length - 1].date)}`
: ''; : '';
@ -369,6 +426,7 @@ const TaughtLessonsPage: React.FC = () => {
key={lesson.id} key={lesson.id}
lesson={lesson} lesson={lesson}
onEdit={setEditingLesson} onEdit={setEditingLesson}
onAssignPlan={openAssignDialog}
/> />
)) ))
)} )}
@ -382,6 +440,71 @@ const TaughtLessonsPage: React.FC = () => {
onClose={() => setEditingLesson(null)} onClose={() => setEditingLesson(null)}
onSave={handleSaveLesson} 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> </Box>
); );
}; };

View File

@ -19,7 +19,9 @@ import {
GridOn as GridIcon, GridOn as GridIcon,
Settings as SetupIcon, Settings as SetupIcon,
Edit as EditIcon, Edit as EditIcon,
Launch as LaunchIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useNavigationStore } from '../../../../../../stores/navigationStore'; import { useNavigationStore } from '../../../../../../stores/navigationStore';
import { useAuth } from '../../../../../../contexts/AuthContext'; import { useAuth } from '../../../../../../contexts/AuthContext';
import { NeoGraphNode } from '../../../../../../types/navigation'; import { NeoGraphNode } from '../../../../../../types/navigation';
@ -124,6 +126,7 @@ interface TreeItemProps {
function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) { function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
const ctx = useContext(NavPanelContext); const ctx = useContext(NavPanelContext);
const navigate = useNavigate();
const [expanded, setExpanded] = useState(node.is_section && node.status === 'populated'); const [expanded, setExpanded] = useState(node.is_section && node.status === 'populated');
const [children, setChildren] = useState<TreeNode[]>(node.children || []); const [children, setChildren] = useState<TreeNode[]>(node.children || []);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -132,6 +135,7 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
const isCalendarSection = isSection && node.section_id === 'calendar'; const isCalendarSection = isSection && node.section_id === 'calendar';
const isTimetableSection = isSection && node.section_id === 'timetable'; const isTimetableSection = isSection && node.section_id === 'timetable';
const isSchoolSection = isSection && node.section_id === 'school'; const isSchoolSection = isSection && node.section_id === 'school';
const isClassesSection = isSection && node.section_id === 'classes';
const SectionIcon = node.section_id ? SECTION_ICONS[node.section_id] : null; const SectionIcon = node.section_id ? SECTION_ICONS[node.section_id] : null;
const Icon = SectionIcon || NODE_ICONS[node.node_type] || HomeIcon; const Icon = SectionIcon || NODE_ICONS[node.node_type] || HomeIcon;
@ -196,6 +200,8 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
const showLegacySetup = isTimetableSection && node.status === 'empty' && !ss; const showLegacySetup = isTimetableSection && node.status === 'empty' && !ss;
const showTimetableEdit = isTimetableSection && node.status === 'populated' const showTimetableEdit = isTimetableSection && node.status === 'populated'
&& ss && ss.status !== 'no_school' && !!ss.teacher_has_timetable; && ss && ss.status !== 'no_school' && !!ss.teacher_has_timetable;
const showTimetableView = isTimetableSection && node.status === 'populated';
const showClassesView = isClassesSection && node.status === 'populated';
if (isSection) { if (isSection) {
return ( return (
@ -223,7 +229,7 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
</IconButton> </IconButton>
) )
)} )}
{isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && ( {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && !isClassesSection && (
<Box sx={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Box sx={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{node.status === 'no_school' {node.status === 'no_school'
? <UnlinkedIcon sx={{ fontSize: 11, color: 'text.disabled' }} /> ? <UnlinkedIcon sx={{ fontSize: 11, color: 'text.disabled' }} />
@ -249,7 +255,7 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
{node.label} {node.label}
</Typography> </Typography>
{isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && node.status && ( {isEmpty && !isCalendarSection && !isTimetableSection && !isSchoolSection && !isClassesSection && node.status && (
<Tooltip title={STATUS_MESSAGES[node.status]} placement="right"> <Tooltip title={STATUS_MESSAGES[node.status]} placement="right">
<Typography variant="caption" sx={{ fontSize: '0.6rem', color: 'text.disabled', ml: 0.5, flexShrink: 0 }}> <Typography variant="caption" sx={{ fontSize: '0.6rem', color: 'text.disabled', ml: 0.5, flexShrink: 0 }}>
{node.status === 'no_school' ? '—' : '…'} {node.status === 'no_school' ? '—' : '…'}
@ -319,6 +325,28 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
{showTimetableView && (
<Tooltip title="View as lesson list" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'text.secondary' }}
onClick={e => { e.stopPropagation(); navigate('/my-lessons'); }}
>
<LaunchIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
{showClassesView && (
<Tooltip title="View my classes" placement="right">
<IconButton
size="small"
sx={{ p: 0.25, ml: 0.5, color: 'text.secondary' }}
onClick={e => { e.stopPropagation(); navigate('/my-classes'); }}
>
<LaunchIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
)}
</Box> </Box>
{/* Calendar mode toggle */} {/* Calendar mode toggle */}