diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index f091bfb..e9d103a 100644
--- a/src/AppRoutes.tsx
+++ b/src/AppRoutes.tsx
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser';
-import { ExamDashboardPage } from './pages/exam';
+import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
@@ -169,6 +169,7 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
} />
@@ -183,6 +184,8 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/src/pages/exam/ExamMarkingPage.tsx b/src/pages/exam/ExamMarkingPage.tsx
new file mode 100644
index 0000000..60a17e2
--- /dev/null
+++ b/src/pages/exam/ExamMarkingPage.tsx
@@ -0,0 +1,233 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { v5 as uuidv5 } from 'uuid';
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Chip,
+ CircularProgress,
+ Container,
+ Divider,
+ List,
+ ListItemButton,
+ ListItemText,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import SaveIcon from '@mui/icons-material/Save';
+import TableChartIcon from '@mui/icons-material/TableChart';
+
+import { examRepository } from '../../services/exam/examRepository';
+import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
+
+const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0';
+
+function stableMarkId(batchId: string, submissionId: string, questionId: string) {
+ return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE);
+}
+
+const ExamMarkingPage: React.FC = () => {
+ const { batchId } = useParams<{ batchId: string }>();
+ const navigate = useNavigate();
+ const [queue, setQueue] = useState(null);
+ const [template, setTemplate] = useState(null);
+ const [selectedId, setSelectedId] = useState(null);
+ const [marks, setMarks] = useState>({});
+ const [comments, setComments] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useState(null);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!batchId) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const nextQueue = await examRepository.getBatchQueue(batchId);
+ setQueue(nextQueue);
+ setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id));
+ setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, [batchId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const markableQuestions = useMemo(
+ () => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
+ [template],
+ );
+ const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
+
+ const saveSelected = async () => {
+ if (!batchId || !selected) return;
+ setSaving(true);
+ setError(null);
+ setMessage(null);
+ try {
+ const writes = markableQuestions
+ .map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] }))
+ .filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim());
+ for (const { q, raw, comment } of writes) {
+ const awarded = raw === undefined || raw === '' ? 0 : Number(raw);
+ if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) {
+ throw new Error(`Invalid mark for ${q.label}`);
+ }
+ await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), {
+ submission_id: selected.id,
+ question_id: q.id,
+ awarded_marks: awarded,
+ comment: comment?.trim() || undefined,
+ confirmed: true,
+ });
+ }
+ setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`);
+ setMarks({});
+ setComments({});
+ await load();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const chooseStudent = (submission: StudentSubmission) => {
+ setSelectedId(submission.id);
+ setMarks({});
+ setComments({});
+ setMessage(null);
+ };
+
+ if (loading) {
+ return ;
+ }
+
+ if (error && !queue) {
+ return (
+
+ {error}
+ } onClick={() => navigate('/exam-marker')}>
+ Back to Exam Marker
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ } onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
+ Exam Marker
+
+ {queue?.batch.title || 'Mark exam'}
+
+ {template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
+
+
+ } onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
+ Results
+
+
+
+ {error && setError(null)}>{error}}
+ {message && setMessage(null)}>{message}}
+ {markableQuestions.length === 0 && (
+
+ This template has no markable parts yet. Add parts in template setup before entering marks.
+
+ )}
+
+
+
+
+ Marking queue
+
+
+
+
+
+
+ {(queue?.submissions ?? []).map((submission) => (
+ chooseStudent(submission)}
+ sx={{ borderRadius: 1, mb: 0.5 }}
+ >
+
+
+
+ ))}
+
+
+
+
+
+
+ {selected ? (
+
+
+ {selected.student_name || selected.student_id || 'Unknown student'}
+ Status: {selected.status}
+
+
+ {markableQuestions.map((q: ExamQuestion) => (
+
+
+ {q.label}
+ / {q.max_marks} marks
+
+ setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
+ sx={{ width: { xs: '100%', sm: 120 } }}
+ />
+ setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
+ sx={{ flex: 1 }}
+ />
+
+ ))}
+
+
+ } onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
+ {saving ? 'Saving…' : 'Save marks'}
+
+
+
+ ) : (
+ No submissions in this batch.
+ )}
+
+
+
+
+
+ );
+};
+
+export default ExamMarkingPage;
diff --git a/src/pages/exam/ExamResultsPage.tsx b/src/pages/exam/ExamResultsPage.tsx
new file mode 100644
index 0000000..115656c
--- /dev/null
+++ b/src/pages/exam/ExamResultsPage.tsx
@@ -0,0 +1,177 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Container,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+} from '@mui/material';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import DownloadIcon from '@mui/icons-material/Download';
+import EditIcon from '@mui/icons-material/Edit';
+
+import { examRepository } from '../../services/exam/examRepository';
+import type { BatchResultsResponse } from '../../types/exam.types';
+
+function formatMark(value: number | null | undefined) {
+ return value === null || value === undefined ? '' : String(value);
+}
+
+const ExamResultsPage: React.FC = () => {
+ const { batchId } = useParams<{ batchId: string }>();
+ const navigate = useNavigate();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [downloading, setDownloading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!batchId) return;
+ setLoading(true);
+ setError(null);
+ try {
+ setData(await examRepository.getBatchResults(batchId));
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, [batchId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const summary = useMemo(() => {
+ const rows = data?.results ?? [];
+ const presentTotals = rows
+ .map((r) => r.total)
+ .filter((v): v is number => typeof v === 'number');
+ const average = presentTotals.length
+ ? presentTotals.reduce((sum, v) => sum + v, 0) / presentTotals.length
+ : null;
+ return {
+ total: rows.length,
+ absent: rows.filter((r) => r.status === 'absent' && r.total === null).length,
+ marked: rows.filter((r) => r.total !== null).length,
+ average,
+ };
+ }, [data]);
+
+ const downloadCsv = async () => {
+ if (!batchId) return;
+ setDownloading(true);
+ setError(null);
+ try {
+ const csv = await examRepository.getBatchCsv(batchId);
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `batch-${batchId}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setDownloading(false);
+ }
+ };
+
+ if (loading) {
+ return ;
+ }
+
+ if (error || !data) {
+ return (
+
+ {error || 'Results not found'}
+ } onClick={() => navigate('/exam-marker')}>
+ Back to Exam Marker
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ } onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
+ Exam Marker
+
+
+ {data.batch.title || 'Exam results'}
+
+
+ Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')}
+
+
+
+ } onClick={() => navigate(`/exam-marker/${data.batch.id}/mark`)}>
+ Mark
+
+ } onClick={downloadCsv} disabled={downloading}>
+ {downloading ? 'Preparing…' : 'Download CSV'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Student
+ Status
+ {data.questions.map((q) => (
+ {q.label} / {q.max_marks}
+ ))}
+ Total
+
+
+
+ {data.results.map((row) => (
+
+
+ {row.student_name || row.student_id || 'Unknown student'}
+ {row.student_id && {row.student_id}}
+
+
+
+
+ {data.questions.map((q) => (
+ {formatMark(row.marks[q.id])}
+ ))}
+ {formatMark(row.total)}
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default ExamResultsPage;
diff --git a/src/pages/exam/ResultsWidget.tsx b/src/pages/exam/ResultsWidget.tsx
new file mode 100644
index 0000000..854900b
--- /dev/null
+++ b/src/pages/exam/ResultsWidget.tsx
@@ -0,0 +1,169 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Chip,
+ CircularProgress,
+ MenuItem,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+import AssessmentIcon from '@mui/icons-material/Assessment';
+import PlayArrowIcon from '@mui/icons-material/PlayArrow';
+import TableChartIcon from '@mui/icons-material/TableChart';
+
+import { examRepository } from '../../services/exam/examRepository';
+import type { BatchResultsResponse, ExamTemplate, MarkingBatch } from '../../types/exam.types';
+
+interface ResultsWidgetProps {
+ classId: string;
+ className?: string;
+}
+
+function averageFromResults(results: BatchResultsResponse | null) {
+ const totals = (results?.results ?? [])
+ .map((row) => row.total)
+ .filter((value): value is number => typeof value === 'number');
+ if (!totals.length) return null;
+ return totals.reduce((sum, value) => sum + value, 0) / totals.length;
+}
+
+const ResultsWidget: React.FC = ({ classId, className }) => {
+ const navigate = useNavigate();
+ const [templates, setTemplates] = useState([]);
+ const [batches, setBatches] = useState([]);
+ const [latestResults, setLatestResults] = useState(null);
+ const [selectedTemplateId, setSelectedTemplateId] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [nextTemplates, nextBatches] = await Promise.all([
+ examRepository.listTemplates(),
+ examRepository.listBatches(),
+ ]);
+ const classBatches = nextBatches
+ .filter((batch) => batch.class_id === classId)
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ setTemplates(nextTemplates);
+ setSelectedTemplateId((current) => current || nextTemplates[0]?.id || '');
+ setBatches(classBatches);
+ setLatestResults(classBatches[0] ? await examRepository.getBatchResults(classBatches[0].id) : null);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, [classId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const latestBatch = batches[0] ?? null;
+ const average = useMemo(() => averageFromResults(latestResults), [latestResults]);
+ const absent = latestResults?.results.filter((row) => row.status === 'absent' && row.total === null).length ?? 0;
+
+ const createBatch = async () => {
+ if (!selectedTemplateId) return;
+ setCreating(true);
+ setError(null);
+ try {
+ const template = templates.find((item) => item.id === selectedTemplateId);
+ const created = await examRepository.createBatch({
+ template_id: selectedTemplateId,
+ class_id: classId,
+ title: `${className || 'Class'} · ${template?.title || 'Exam'}`,
+ });
+ navigate(`/exam-marker/${created.id}/mark`);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ Assessment results
+ Last exam summary for this class
+
+
+ {loading && }
+
+
+ {error && setError(null)}>{error}}
+
+ {latestBatch ? (
+
+
+ {latestBatch.title || 'Exam batch'}
+
+ {new Date(latestBatch.created_at).toLocaleDateString('en-GB')}
+
+
+
+
+
+
+
+
+ } onClick={() => navigate(`/exam-marker/${latestBatch.id}/results`)}>
+ View results
+
+
+
+
+ ) : !loading ? (
+
+ No exam batches have been created for this class yet.
+
+ ) : null}
+
+
+ setSelectedTemplateId(e.target.value)}
+ disabled={!templates.length || creating}
+ sx={{ minWidth: 260 }}
+ >
+ {templates.map((template) => (
+
+ ))}
+
+ }
+ onClick={createBatch}
+ disabled={!selectedTemplateId || creating}
+ >
+ {creating ? 'Creating…' : 'Create marking batch'}
+
+
+
+
+
+ );
+};
+
+export default ResultsWidget;
diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts
index d8b8c5c..d9f4bcd 100644
--- a/src/pages/exam/index.ts
+++ b/src/pages/exam/index.ts
@@ -1 +1,4 @@
export { default as ExamDashboardPage } from './ExamDashboardPage';
+export { default as ExamMarkingPage } from './ExamMarkingPage';
+export { default as ExamResultsPage } from './ExamResultsPage';
+export { default as ResultsWidget } from './ResultsWidget';
diff --git a/src/pages/timetable/ClassDetailPage.tsx b/src/pages/timetable/ClassDetailPage.tsx
index 399125b..277399f 100644
--- a/src/pages/timetable/ClassDetailPage.tsx
+++ b/src/pages/timetable/ClassDetailPage.tsx
@@ -9,6 +9,7 @@ import {
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
+import { ResultsWidget } from '../exam';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
@@ -131,10 +132,20 @@ const ClassDetailPage: React.FC = () => {
setLoading(true);
setError(null);
try {
- const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
+ const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json());
- if (clsRes.id) setCls(clsRes);
+ if (clsRes.id) {
+ setCls({
+ ...clsRes,
+ class_code: clsRes.class_code || clsRes.code,
+ year_group: clsRes.year_group || clsRes.school_year,
+ teachers: clsRes.teachers || [],
+ students: clsRes.students || [],
+ enrollment_requests: clsRes.enrollment_requests || [],
+ student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
+ });
+ }
else setError(clsRes.detail || 'Class not found');
const role = bootstrapData?.active_institute?.membership_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head');
@@ -174,20 +185,20 @@ const ClassDetailPage: React.FC = () => {
const handleAddStudent = async (studentId: string) => {
setActionError(null);
- const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
+ const res = await apiPost(`/database/timetable/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}`);
+ await apiDelete(`/database/timetable/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 });
+ const res = await apiPost(`/database/timetable/enrollment-requests/${requestId}/respond`, { status: action === 'approve' ? 'approved' : 'rejected' });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Action failed');
};
@@ -254,6 +265,8 @@ const ClassDetailPage: React.FC = () => {
)}
+
+
{/* Tabs */}
setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts
index 1b5160d..b78ed4f 100644
--- a/src/services/exam/examRepository.ts
+++ b/src/services/exam/examRepository.ts
@@ -10,9 +10,14 @@ import axios from 'axios';
import { API_BASE } from '../../config/apiConfig';
import { supabase } from '../../supabaseClient';
import type {
+ BatchQueueResponse,
+ BatchResultsResponse,
+ CreateBatchPayload,
CreateTemplatePayload,
ExamTemplate,
ExamTemplateDetail,
+ MarkingBatch,
+ MarkUpsertPayload,
} from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`;
@@ -51,6 +56,48 @@ export const examRepository = {
const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
},
+
+ async createBatch(payload: CreateBatchPayload): Promise {
+ const headers = await authHeaders();
+ const res = await axios.post(`${EXAM_BASE}/batches`, payload, { headers });
+ return res.data;
+ },
+
+ async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise {
+ const headers = await authHeaders();
+ const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
+ headers,
+ params: {
+ include_archived: params.includeArchived ?? false,
+ template_id: params.templateId,
+ },
+ });
+ return res.data.batches ?? [];
+ },
+
+ async getBatchQueue(batchId: string): Promise {
+ const headers = await authHeaders();
+ const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
+ return res.data;
+ },
+
+ async getBatchResults(batchId: string): Promise {
+ const headers = await authHeaders();
+ const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
+ return res.data;
+ },
+
+ async getBatchCsv(batchId: string): Promise {
+ const headers = await authHeaders();
+ const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
+ return res.data;
+ },
+
+ async upsertMark(markId: string, payload: MarkUpsertPayload): Promise {
+ const headers = await authHeaders();
+ const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
+ return res.data;
+ },
};
export default examRepository;
diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts
index 017e0d1..32a419b 100644
--- a/src/types/exam.types.ts
+++ b/src/types/exam.types.ts
@@ -76,3 +76,68 @@ export interface ExamTemplateDetail extends ExamTemplate {
response_areas: ExamResponseArea[];
boundaries: ExamBoundary[];
}
+
+export interface MarkingBatch {
+ id: string;
+ template_id: string;
+ class_id: string | null;
+ institute_id: string;
+ teacher_id: string;
+ title: string | null;
+ status: 'open' | 'closed' | 'archived' | string;
+ created_at: string;
+ updated_at?: string;
+ submission_count?: number;
+}
+
+export interface StudentSubmission {
+ id: string;
+ batch_id: string;
+ student_id: string | null;
+ student_name: string | null;
+ status: 'absent' | 'unmatched' | 'matched' | 'marking' | 'complete' | string;
+ storage_path?: string | null;
+ mark_entry_count?: number;
+}
+
+export interface BatchQueueResponse {
+ batch: MarkingBatch;
+ submissions: StudentSubmission[];
+ progress: {
+ total: number;
+ absent: number;
+ complete: number;
+ in_progress: number;
+ };
+}
+
+export interface ExamResultRow {
+ submission_id: string;
+ student_id: string | null;
+ student_name: string | null;
+ status: string | null;
+ marks: Record;
+ total: number | null;
+}
+
+export interface BatchResultsResponse {
+ batch: MarkingBatch;
+ questions: Array>;
+ results: ExamResultRow[];
+}
+
+export interface CreateBatchPayload {
+ template_id: string;
+ class_id?: string;
+ title?: string;
+}
+
+export interface MarkUpsertPayload {
+ submission_id: string;
+ question_id: string;
+ awarded_marks: number;
+ mark_scheme_detail?: Record;
+ annotation_shape_ids?: unknown;
+ comment?: string;
+ confirmed?: boolean;
+}