diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx index 59616e3..12fc485 100644 --- a/src/AppRoutes.admin.test.tsx +++ b/src/AppRoutes.admin.test.tsx @@ -35,6 +35,9 @@ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
, ExamTemplateSetupPage: () =>
Exam Template Setup
, MarkSchemePage: () =>
Mark Scheme editor
, + ExamMarkingPage: () =>
Exam Marking
, + ExamResultsPage: () =>
Exam Results
, + ResultsWidget: () =>
Results Widget
, })); vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
})); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 681a452..fabc000 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, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam'; +import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam'; import { ErrorBoundary } from './components/ErrorBoundary'; import CalendarPage from './pages/user/calendarPage'; import SettingsPage from './pages/user/settingsPage'; @@ -185,6 +185,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} + + + ); + } + + return ( + + + + + + {queue?.batch.title || 'Mark exam'} + + {template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue + + + + + + {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 }} + /> + + ))} + + + + + + ) : ( + 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'} + + + ); + } + + return ( + + + + + + + {data.batch.title || 'Exam results'} + + + Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')} + + + + + + + + + + + + + + + + + + + + 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')} + + + + + + + + + + + + + ) : !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) => ( + {template.title} + ))} + + + + + + + ); +}; + +export default ResultsWidget; diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts index f86afc0..21f10c6 100644 --- a/src/pages/exam/index.ts +++ b/src/pages/exam/index.ts @@ -1,3 +1,6 @@ export { default as ExamDashboardPage } from './ExamDashboardPage'; export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage'; export { default as MarkSchemePage } from './MarkSchemePage'; +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..4d763de 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'; @@ -254,6 +255,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 b12bd1a..8f0b9e3 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -11,12 +11,17 @@ import { API_BASE } from '../../config/apiConfig'; import { logger } from '../../debugConfig'; import { supabase } from '../../supabaseClient'; import type { + BatchQueueResponse, + BatchResultsResponse, + CreateBatchPayload, CreateTemplatePayload, ExamBoundary, ExamQuestion, ExamResponseArea, ExamTemplate, ExamTemplateDetail, + MarkingBatch, + MarkUpsertPayload, Neo4jSyncResult, PatchQuestionPayload, SpecPoint, @@ -209,6 +214,48 @@ export const examRepository = { const res = await axios.post(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers }); return res.data; }, + + 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 b867d50..1b65186 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -201,3 +201,68 @@ export interface Neo4jSyncResult { status: string; projection?: Record; } + +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; +}