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:
parent
9438e17f88
commit
510bef02b6
@ -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,11 +177,18 @@ 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>
|
||||||
<Tooltip title="Edit lesson">
|
<Box sx={{ display: 'flex', gap: 0 }}>
|
||||||
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ ml: 0.5, p: 0.25 }}>
|
<Tooltip title="Assign lesson plan">
|
||||||
<EditNote fontSize="inherit" />
|
<IconButton size="small" onClick={() => onAssignPlan(lesson)} sx={{ p: 0.25 }}>
|
||||||
</IconButton>
|
<LibraryAdd fontSize="inherit" />
|
||||||
</Tooltip>
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Edit lesson">
|
||||||
|
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ p: 0.25 }}>
|
||||||
|
<EditNote fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</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' }}>
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user