app/src/pages/timetable/ClassDetailPage.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

429 lines
19 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, CircularProgress, Alert, Chip, Divider,
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
Autocomplete, IconButton, Tooltip, Tabs, Tab, Avatar,
} from '@mui/material';
import {
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
} 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 Profile { id: string; full_name: string; display_name?: string; email: string; }
interface ClassTeacher { teacher_id: string; is_primary: boolean; can_edit: boolean; profile: Profile; }
interface ClassStudent { student_id: string; status: string; enrolled_at: string; profile: Profile; }
interface EnrollmentRequest { id: string; student_id: string; status: string; created_at: string; profile: Profile; }
interface ClassDetail {
id: string;
name: string;
class_code?: string;
subject?: string;
year_group?: string;
description?: string;
is_active: boolean;
teachers: ClassTeacher[];
students: ClassStudent[];
enrollment_requests: EnrollmentRequest[];
student_count: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function initials(name: string) {
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
}
// ─── Add Student Dialog ───────────────────────────────────────────────────────
interface AddStudentDialogProps {
open: boolean;
onClose: () => void;
onAdd: (studentId: string) => Promise<void>;
accessToken: string;
existingIds: Set<string>;
}
function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: AddStudentDialogProps) {
const [allStudents, setAllStudents] = useState<Profile[]>([]);
const [selected, setSelected] = useState<Profile | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setLoading(true);
fetch(`${API_BASE}/classes/school/students`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then(r => r.json())
.then(d => setAllStudents((d.students || []).filter((s: Profile) => !existingIds.has(s.id))))
.finally(() => setLoading(false));
}, [open, accessToken, existingIds]);
const handleAdd = async () => {
if (!selected) return;
setSaving(true);
await onAdd(selected.id);
setSaving(false);
setSelected(null);
onClose();
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Add Student to Class</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={28} />
</Box>
) : (
<Autocomplete
options={allStudents}
getOptionLabel={o => `${o.full_name} (${o.email})`}
value={selected}
onChange={(_, v) => setSelected(v)}
renderInput={params => (
<TextField {...params} label="Search students" size="small" autoFocus />
)}
sx={{ mt: 1 }}
/>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button
onClick={handleAdd}
variant="contained"
disabled={!selected || saving}
startIcon={saving ? <CircularProgress size={16} /> : <PersonAdd />}
>
Add
</Button>
</DialogActions>
</Dialog>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const ClassDetailPage: React.FC = () => {
const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate();
const { accessToken, user, bootstrapData } = useAuth();
const [cls, setCls] = useState<ClassDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState(0);
const [isAdmin, setIsAdmin] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!accessToken || !classId) return;
setLoading(true);
setError(null);
try {
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json());
if (clsRes.id) setCls(clsRes);
else setError(clsRes.detail || 'Class not found');
const role = bootstrapData?.active_institute?.membership_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken, classId, bootstrapData]);
useEffect(() => { load(); }, [load]);
const apiPost = async (path: string, body?: object) => {
const r = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
return r.json();
};
const apiDelete = async (path: string) => {
await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
};
const apiPatch = async (path: string, body: object) => {
const r = await fetch(`${API_BASE}${path}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
};
const handleAddStudent = async (studentId: string) => {
setActionError(null);
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Failed to add student');
};
const handleRemoveStudent = async (studentId: string) => {
setActionError(null);
await apiDelete(`/classes/${classId}/students/${studentId}`);
load();
};
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
setActionError(null);
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Action failed');
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
if (error || !cls) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{error || 'Class not found'}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/classes')}>
Back to Classes
</Button>
</Box>
);
}
const existingStudentIds = new Set(cls.students.map(s => s.student_id));
const pendingCount = cls.enrollment_requests.length;
return (
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
{/* Header */}
<Button
size="small"
startIcon={<ArrowBack />}
onClick={() => navigate('/classes')}
sx={{ mb: 2 }}
>
Back to Classes
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" fontWeight={700}>{cls.name}</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
{cls.class_code && <Chip label={cls.class_code} size="small" />}
{cls.subject && <Chip label={cls.subject} size="small" variant="outlined" />}
{cls.year_group && <Chip label={`Y${cls.year_group}`} size="small" variant="outlined" />}
<Chip
label={cls.is_active ? 'Active' : 'Inactive'}
size="small"
color={cls.is_active ? 'success' : 'default'}
/>
</Box>
{cls.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{cls.description}
</Typography>
)}
</Box>
<School sx={{ color: 'primary.main', fontSize: 40, opacity: 0.3 }} />
</Box>
{actionError && (
<Alert severity="error" onClose={() => setActionError(null)} sx={{ mb: 2 }}>
{actionError}
</Alert>
)}
{/* Tabs */}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tab label={`Students (${cls.student_count})`} />
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
<Tab label={`Teachers (${cls.teachers.length})`} />
</Tabs>
{/* Students tab */}
{tab === 0 && (
<Box>
{isAdmin && (
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="small"
startIcon={<PersonAdd />}
onClick={() => setAddOpen(true)}
>
Add Student
</Button>
</Box>
)}
{cls.students.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No students enrolled yet
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.students.map(s => (
<Box
key={s.student_id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'primary.light' }}>
{initials(s.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{s.profile?.full_name || s.student_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{s.profile?.email}
</Typography>
</Box>
{isAdmin && (
<Tooltip title="Remove student">
<IconButton
size="small"
color="error"
onClick={() => handleRemoveStudent(s.student_id)}
>
<PersonRemove fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{/* Enrollment requests tab */}
{tab === 1 && (
<Box>
{cls.enrollment_requests.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No pending enrollment requests
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.enrollment_requests.map(req => (
<Box
key={req.id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'warning.light',
borderRadius: 1, bgcolor: 'warning.50',
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'warning.light' }}>
{initials(req.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{req.profile?.full_name || req.student_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{req.profile?.email} · requested{' '}
{new Date(req.created_at).toLocaleDateString('en-GB')}
</Typography>
</Box>
{isAdmin && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Approve">
<IconButton
size="small"
color="success"
onClick={() => handleEnrollmentResponse(req.id, 'approve')}
>
<CheckCircle fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Reject">
<IconButton
size="small"
color="error"
onClick={() => handleEnrollmentResponse(req.id, 'reject')}
>
<Cancel fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{/* Teachers tab */}
{tab === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.teachers.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No teachers assigned
</Typography>
) : (
cls.teachers.map(t => (
<Box
key={t.teacher_id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'secondary.light' }}>
{initials(t.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{t.profile?.full_name || t.teacher_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{t.profile?.email}
</Typography>
</Box>
{t.is_primary && (
<Chip label="Primary" size="small" color="primary" />
)}
</Box>
))
)}
</Box>
)}
<AddStudentDialog
open={addOpen}
onClose={() => setAddOpen(false)}
onAdd={handleAddStudent}
accessToken={accessToken || ''}
existingIds={existingStudentIds}
/>
</Box>
);
};
export default ClassDetailPage;