app/src/pages/timetable/StudentLessonsPage.tsx
kcar fedbd903ff
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
fix: centralize app API URL fallbacks
2026-05-28 19:26:00 +01:00

209 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;