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 (
+
+ );
+}
+
+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)}
+
+
+ } onClick={downloadCsv} disabled={grid.assessments.length === 0}>CSV
+ } onClick={() => setAddOpen(true)}>Add assessment
+
+
+
+ {grid.assessments.length === 0 ? (
+
+ No assessment columns yet.
+ } onClick={() => setAddOpen(true)}>Add first assessment
+
+ ) : (
+
+
+
+
+ #
+ 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;