From 900e2613b4c2205dfe03b2019d715d8dffa5faff Mon Sep 17 00:00:00 2001 From: "Kevin Carter (via Claude)" Date: Fri, 24 Jul 2026 18:18:50 +0000 Subject: [PATCH] Add class markbook tab --- src/pages/timetable/ClassDetailPage.tsx | 11 +- src/pages/timetable/MarkbookPanel.tsx | 319 ++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 src/pages/timetable/MarkbookPanel.tsx diff --git a/src/pages/timetable/ClassDetailPage.tsx b/src/pages/timetable/ClassDetailPage.tsx index 96157cb..5dfb5b0 100644 --- a/src/pages/timetable/ClassDetailPage.tsx +++ b/src/pages/timetable/ClassDetailPage.tsx @@ -10,6 +10,7 @@ import { } from '@mui/icons-material'; import { useAuth } from '../../contexts/AuthContext'; import { ResultsWidget } from '../exam'; +import MarkbookPanel from './MarkbookPanel'; const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api'; @@ -270,6 +271,7 @@ const ClassDetailPage: React.FC = () => { {/* Tabs */} setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}> + 0 ? ` (${pendingCount})` : ''}`} /> @@ -332,8 +334,13 @@ const ClassDetailPage: React.FC = () => { )} - {/* Enrollment requests tab */} + {/* Markbook tab */} {tab === 1 && ( + + )} + + {/* Enrollment requests tab */} + {tab === 2 && ( {cls.enrollment_requests.length === 0 ? ( @@ -392,7 +399,7 @@ const ClassDetailPage: React.FC = () => { )} {/* Teachers tab */} - {tab === 2 && ( + {tab === 3 && ( {cls.teachers.length === 0 ? ( diff --git a/src/pages/timetable/MarkbookPanel.tsx b/src/pages/timetable/MarkbookPanel.tsx new file mode 100644 index 0000000..145fcf9 --- /dev/null +++ b/src/pages/timetable/MarkbookPanel.tsx @@ -0,0 +1,319 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import DownloadIcon from '@mui/icons-material/Download'; + +import { API_BASE } from '../../config/apiConfig'; + +interface Assessment { + id: string; + title: string; + date?: string | null; + max_marks: number; +} + +interface MarkbookStudent { + student_id: string; + student_name: string; + row_number: number; + marks: Record; + total: number | null; + percentage: number | null; +} + +interface AssessmentSummary { + assessment_id: string; + entered_count: number; + average_mark: number | null; + average_percentage: number | null; +} + +interface MarkbookGrid { + assessments: Assessment[]; + students: MarkbookStudent[]; + assessment_summaries: AssessmentSummary[]; + summary: { + student_count: number; + assessment_count: number; + entered_mark_count: number; + class_average_mark: number | null; + }; +} + +interface MarkbookPanelProps { + classId: string; + accessToken: string; +} + +const MARKBOOK_BASE = `${API_BASE}/api/markbook`; + +function formatNumber(value: number | null | undefined) { + return value === null || value === undefined ? '—' : Number(value).toFixed(Number.isInteger(value) ? 0 : 1); +} + +function markInputValue(value: number | null | undefined) { + return value === null || value === undefined ? '' : String(value); +} + +function AddAssessmentDialog({ + open, + saving, + onClose, + onCreate, +}: { + open: boolean; + saving: boolean; + onClose: () => void; + onCreate: (payload: { title: string; date?: string; max_marks: number }) => Promise; +}) { + const [title, setTitle] = useState(''); + const [date, setDate] = useState(''); + const [maxMarks, setMaxMarks] = useState('100'); + + useEffect(() => { + if (open) { + setTitle(''); + setDate(''); + setMaxMarks('100'); + } + }, [open]); + + const handleCreate = async () => { + const parsedMax = Number(maxMarks); + if (!title.trim() || !Number.isFinite(parsedMax) || parsedMax <= 0) return; + await onCreate({ title: title.trim(), date: date || undefined, max_marks: parsedMax }); + }; + + return ( + + Add assessment column + + + setTitle(e.target.value)} autoFocus /> + setDate(e.target.value)} InputLabelProps={{ shrink: true }} /> + setMaxMarks(e.target.value)} inputProps={{ min: 0, step: 0.5 }} /> + + + + + + + + ); +} + +const MarkbookPanel: React.FC = ({ classId, accessToken }) => { + const [grid, setGrid] = useState(null); + const [loading, setLoading] = useState(true); + const [savingKey, setSavingKey] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [savingAssessment, setSavingAssessment] = useState(false); + const [error, setError] = useState(null); + + const headers = useMemo(() => ({ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }), [accessToken]); + + const load = useCallback(async () => { + if (!classId || !accessToken) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/grid`, { headers }); + const body = await res.json(); + if (!res.ok) throw new Error(body.detail || 'Failed to load markbook'); + setGrid(body); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [accessToken, classId, headers]); + + useEffect(() => { + void load(); + }, [load]); + + const createAssessment = async (payload: { title: string; date?: string; max_marks: number }) => { + setSavingAssessment(true); + setError(null); + try { + const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/assessments`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.detail || 'Failed to create assessment'); + setAddOpen(false); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSavingAssessment(false); + } + }; + + const saveMark = async (studentId: string, assessment: Assessment, raw: string) => { + const key = `${studentId}:${assessment.id}`; + const trimmed = raw.trim(); + const mark = trimmed === '' ? null : Number(trimmed); + if (mark !== null && (!Number.isFinite(mark) || mark < 0 || mark > Number(assessment.max_marks))) { + setError(`Mark must be between 0 and ${assessment.max_marks}`); + return; + } + setSavingKey(key); + setError(null); + try { + const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/assessments/${assessment.id}/marks/${studentId}`, { + method: 'PUT', + headers, + body: JSON.stringify({ mark }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.detail || 'Failed to save mark'); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSavingKey(null); + } + }; + + const downloadCsv = async () => { + setError(null); + try { + const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/csv`, { headers }); + const text = await res.text(); + if (!res.ok) throw new Error(text || 'Failed to export CSV'); + const blob = new Blob([text], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `markbook-${classId}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + if (loading) { + return ; + } + + if (!grid) { + return {error || 'Could not load markbook'}; + } + + const summaryByAssessment = new Map(grid.assessment_summaries.map((s) => [s.assessment_id, s])); + + return ( + + {error && setError(null)} sx={{ mb: 2 }}>{error}} + + + {grid.summary.student_count} roster students + {grid.summary.assessment_count} assessments + Class average mark {formatNumber(grid.summary.class_average_mark)} + + + + + + + + {grid.assessments.length === 0 ? ( + + No assessment columns yet. + + + ) : ( + + + + + # + Student + {grid.assessments.map((assessment) => ( + + {assessment.title} + + {assessment.date ? `${new Date(assessment.date).toLocaleDateString('en-GB')} · ` : ''}/{assessment.max_marks} + + + ))} + Total + % + + + + {grid.students.map((student) => ( + + {student.row_number} + + {student.student_name || student.student_id} + {student.student_id} + + {grid.assessments.map((assessment) => { + const key = `${student.student_id}:${assessment.id}`; + return ( + + void saveMark(student.student_id, assessment, e.target.value)} + disabled={savingKey === key} + inputProps={{ min: 0, max: assessment.max_marks, step: 0.5, style: { textAlign: 'right' } }} + sx={{ width: 86 }} + /> + + ); + })} + {formatNumber(student.total)} + {formatNumber(student.percentage)} + + ))} + + + Average + {grid.assessments.map((assessment) => { + const summary = summaryByAssessment.get(assessment.id); + return {formatNumber(summary?.average_mark)}; + })} + + + + +
+
+ )} + + setAddOpen(false)} onCreate={createAssessment} /> +
+ ); +}; + +export default MarkbookPanel;