513 lines
22 KiB
TypeScript
513 lines
22 KiB
TypeScript
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, List, ListItemButton, ListItemText,
|
||
InputAdornment,
|
||
} from '@mui/material';
|
||
import {
|
||
ChevronLeft, ChevronRight, Today, EditNote, LibraryAdd, Search, OpenInNew,
|
||
} 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 Lesson {
|
||
id: string;
|
||
date: string;
|
||
period_code: string;
|
||
period_name: string;
|
||
start_time: string | null;
|
||
end_time: string | null;
|
||
class_name: string | null;
|
||
subject: string | null;
|
||
year_group: string | null;
|
||
week_cycle: string;
|
||
day_of_week: string;
|
||
status: string;
|
||
lesson_plan: Record<string, any>;
|
||
notes: string | null;
|
||
whiteboard_room_id: string | null;
|
||
}
|
||
|
||
interface DayEntry {
|
||
date: string;
|
||
day_of_week: string;
|
||
is_today: boolean;
|
||
lessons: Lesson[];
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function toMonday(d: Date): string {
|
||
const day = d.getDay();
|
||
const diff = day === 0 ? -6 : 1 - day;
|
||
const monday = new Date(d);
|
||
monday.setDate(d.getDate() + diff);
|
||
return monday.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function addWeeks(isoDate: string, n: number): string {
|
||
const d = new Date(isoDate + 'T00:00:00');
|
||
d.setDate(d.getDate() + n * 7);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function formatDate(isoDate: string): string {
|
||
return new Date(isoDate + 'T00:00:00').toLocaleDateString('en-GB', {
|
||
day: 'numeric', month: 'short',
|
||
});
|
||
}
|
||
|
||
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
||
planned: 'default',
|
||
in_progress: 'primary',
|
||
completed: 'success',
|
||
cancelled: 'error',
|
||
substituted: 'warning',
|
||
};
|
||
|
||
// ─── Lesson edit dialog ───────────────────────────────────────────────────────
|
||
|
||
interface EditDialogProps {
|
||
lesson: Lesson | null;
|
||
onClose: () => void;
|
||
onSave: (id: string, updates: { notes?: string; status?: string }) => Promise<void>;
|
||
}
|
||
|
||
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
|
||
const [notes, setNotes] = useState('');
|
||
const [status, setStatus] = useState('planned');
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (lesson) {
|
||
setNotes(lesson.notes || '');
|
||
setStatus(lesson.status || 'planned');
|
||
}
|
||
}, [lesson]);
|
||
|
||
const handleSave = async () => {
|
||
if (!lesson) return;
|
||
setSaving(true);
|
||
await onSave(lesson.id, { notes, status });
|
||
setSaving(false);
|
||
onClose();
|
||
};
|
||
|
||
if (!lesson) return null;
|
||
|
||
return (
|
||
<Dialog open={!!lesson} onClose={onClose} maxWidth="sm" fullWidth>
|
||
<DialogTitle sx={{ pb: 1 }}>
|
||
<Box>
|
||
<Typography variant="h6">
|
||
{lesson.class_name || lesson.period_code}
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||
{lesson.date} · {lesson.period_name}
|
||
{lesson.start_time && ` · ${lesson.start_time}–${lesson.end_time}`}
|
||
{lesson.week_cycle && ` · Week ${lesson.week_cycle}`}
|
||
</Typography>
|
||
</Box>
|
||
</DialogTitle>
|
||
<DialogContent>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||
<FormControl size="small" fullWidth>
|
||
<InputLabel>Status</InputLabel>
|
||
<Select label="Status" value={status} onChange={e => setStatus(e.target.value)}>
|
||
<MenuItem value="planned">Planned</MenuItem>
|
||
<MenuItem value="in_progress">In Progress</MenuItem>
|
||
<MenuItem value="completed">Completed</MenuItem>
|
||
<MenuItem value="cancelled">Cancelled</MenuItem>
|
||
<MenuItem value="substituted">Substituted</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
<TextField
|
||
label="Notes"
|
||
value={notes}
|
||
onChange={e => setNotes(e.target.value)}
|
||
multiline
|
||
minRows={3}
|
||
fullWidth
|
||
size="small"
|
||
placeholder="Lesson notes, objectives, reminders…"
|
||
/>
|
||
</Box>
|
||
</DialogContent>
|
||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||
<Button onClick={handleSave} variant="contained" disabled={saving}>
|
||
{saving ? <CircularProgress size={18} /> : 'Save'}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ─── Lesson card ──────────────────────────────────────────────────────────────
|
||
|
||
interface LessonCardProps {
|
||
lesson: Lesson;
|
||
onEdit: (l: Lesson) => void;
|
||
onAssignPlan: (l: Lesson) => void;
|
||
}
|
||
|
||
function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
|
||
return (
|
||
<Box
|
||
sx={{
|
||
p: 1.25,
|
||
mb: 0.75,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
borderRadius: 1,
|
||
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
|
||
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 0.5,
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||
{lesson.class_name || lesson.period_code}
|
||
</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">
|
||
<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' }}>
|
||
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
|
||
</Typography>
|
||
)}
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||
{lesson.start_time ? `${lesson.start_time}–${lesson.end_time}` : lesson.period_name}
|
||
</Typography>
|
||
{lesson.week_cycle && (
|
||
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||
)}
|
||
<Chip
|
||
label={lesson.status}
|
||
size="small"
|
||
color={STATUS_COLORS[lesson.status] ?? 'default'}
|
||
sx={{ height: 16, fontSize: '0.65rem' }}
|
||
/>
|
||
</Box>
|
||
{lesson.notes && (
|
||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem', fontStyle: 'italic', mt: 0.25 }}>
|
||
{lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||
|
||
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);
|
||
const [error, setError] = useState<string | null>(null);
|
||
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;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(
|
||
`${API_BASE}/timetable/lessons?week_start=${ws}&weeks=1`,
|
||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||
);
|
||
const data = await res.json();
|
||
if (data.status === 'ok') {
|
||
setDays(data.days);
|
||
} else {
|
||
setError(data.message || 'Failed to load lessons');
|
||
}
|
||
} catch (e: any) {
|
||
setError(e.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [accessToken]);
|
||
|
||
useEffect(() => { loadLessons(weekStart); }, [weekStart, loadLessons]);
|
||
|
||
const goThisWeek = () => setWeekStart(toMonday(new Date()));
|
||
const prevWeek = () => setWeekStart(ws => addWeeks(ws, -1));
|
||
const nextWeek = () => setWeekStart(ws => addWeeks(ws, 1));
|
||
|
||
const handleMaterialize = async () => {
|
||
if (!accessToken) return;
|
||
setMaterializing(true);
|
||
setMaterializeResult(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/timetable/materialize`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
});
|
||
const data = await res.json();
|
||
if (data.status === 'ok') {
|
||
setMaterializeResult(`Created ${data.lessons_upserted} lessons, ${data.whiteboard_rooms_created} rooms`);
|
||
loadLessons(weekStart);
|
||
} else {
|
||
setMaterializeResult(`Error: ${data.message}`);
|
||
}
|
||
} catch (e: any) {
|
||
setMaterializeResult(`Error: ${e.message}`);
|
||
} finally {
|
||
setMaterializing(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveLesson = async (id: string, updates: { notes?: string; status?: string }) => {
|
||
if (!accessToken) return;
|
||
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(updates),
|
||
});
|
||
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 weekLabel = days.length > 0
|
||
? `${formatDate(days[0].date)} – ${formatDate(days[days.length - 1].date)}`
|
||
: '';
|
||
|
||
return (
|
||
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
|
||
{/* Header */}
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||
<Box>
|
||
<Typography variant="h5" sx={{ fontWeight: 700 }}>My Lessons</Typography>
|
||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
|
||
</Typography>
|
||
</Box>
|
||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||
<Button
|
||
size="small"
|
||
variant="outlined"
|
||
onClick={handleMaterialize}
|
||
disabled={materializing}
|
||
sx={{ whiteSpace: 'nowrap' }}
|
||
>
|
||
{materializing ? <CircularProgress size={14} sx={{ mr: 1 }} /> : null}
|
||
Generate Lessons
|
||
</Button>
|
||
<Tooltip title="Go to this week">
|
||
<IconButton size="small" onClick={goThisWeek}><Today fontSize="small" /></IconButton>
|
||
</Tooltip>
|
||
<IconButton size="small" onClick={prevWeek}><ChevronLeft /></IconButton>
|
||
<IconButton size="small" onClick={nextWeek}><ChevronRight /></IconButton>
|
||
</Box>
|
||
</Box>
|
||
|
||
{materializeResult && (
|
||
<Alert
|
||
severity={materializeResult.startsWith('Error') ? 'error' : 'success'}
|
||
onClose={() => setMaterializeResult(null)}
|
||
sx={{ mb: 2 }}
|
||
>
|
||
{materializeResult}
|
||
</Alert>
|
||
)}
|
||
|
||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||
|
||
{/* Week grid */}
|
||
{loading ? (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||
<CircularProgress />
|
||
</Box>
|
||
) : (
|
||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
|
||
{days.map(day => (
|
||
<Box key={day.date}>
|
||
{/* Day header */}
|
||
<Box
|
||
sx={{
|
||
p: 1,
|
||
mb: 0.75,
|
||
borderRadius: 1,
|
||
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
|
||
color: day.is_today ? 'primary.contrastText' : 'text.primary',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
|
||
{day.day_of_week.slice(0, 3)}
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
|
||
{formatDate(day.date)}
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Lesson cards */}
|
||
{day.lessons.length === 0 ? (
|
||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
|
||
No lessons
|
||
</Typography>
|
||
) : (
|
||
day.lessons.map(lesson => (
|
||
<LessonCard
|
||
key={lesson.id}
|
||
lesson={lesson}
|
||
onEdit={setEditingLesson}
|
||
onAssignPlan={openAssignDialog}
|
||
/>
|
||
))
|
||
)}
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
)}
|
||
|
||
<LessonEditDialog
|
||
lesson={editingLesson}
|
||
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>
|
||
);
|
||
};
|
||
|
||
export default TaughtLessonsPage;
|