209 lines
8.8 KiB
TypeScript
209 lines
8.8 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
Box, Typography, CircularProgress, Alert, Chip, IconButton, Tooltip,
|
||
} from '@mui/material';
|
||
import { ChevronLeft, ChevronRight, Today } 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;
|
||
teacher_name: 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 m = new Date(d);
|
||
m.setDate(d.getDate() + diff);
|
||
return m.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function addWeeks(iso: string, n: number): string {
|
||
const d = new Date(iso + 'T00:00:00');
|
||
d.setDate(d.getDate() + n * 7);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function fmtDate(iso: string): string {
|
||
return new Date(iso + '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 card ──────────────────────────────────────────────────────────────
|
||
|
||
function LessonCard({ lesson }: { lesson: Lesson }) {
|
||
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,
|
||
}}
|
||
>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||
{lesson.class_name || lesson.period_code}
|
||
</Typography>
|
||
{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.teacher_name && (
|
||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem' }}>
|
||
{lesson.teacher_name}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||
|
||
const StudentLessonsPage: React.FC = () => {
|
||
const { accessToken } = useAuth();
|
||
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 load = useCallback(async (ws: string) => {
|
||
if (!accessToken) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(
|
||
`${API_BASE}/timetable/student/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(() => { load(weekStart); }, [weekStart, load]);
|
||
|
||
const totalLessons = days.reduce((s, d) => s + d.lessons.length, 0);
|
||
const weekLabel = days.length > 0
|
||
? `${fmtDate(days[0].date)} – ${fmtDate(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" fontWeight={700}>My Lessons</Typography>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
|
||
</Typography>
|
||
</Box>
|
||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||
<Tooltip title="This week">
|
||
<IconButton size="small" onClick={() => setWeekStart(toMonday(new Date()))}>
|
||
<Today fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, -1))}>
|
||
<ChevronLeft />
|
||
</IconButton>
|
||
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, 1))}>
|
||
<ChevronRight />
|
||
</IconButton>
|
||
</Box>
|
||
</Box>
|
||
|
||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||
|
||
{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}>
|
||
<Box
|
||
sx={{
|
||
p: 1, mb: 0.75, borderRadius: 1, textAlign: 'center',
|
||
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
|
||
color: day.is_today ? 'primary.contrastText' : 'text.primary',
|
||
}}
|
||
>
|
||
<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 }}>
|
||
{fmtDate(day.date)}
|
||
</Typography>
|
||
</Box>
|
||
{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} />
|
||
))
|
||
)}
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default StudentLessonsPage;
|