Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a519748d | ||
|
|
3389fdcb5b | ||
|
|
ab35193be1 | ||
|
|
b3f71c5749 | ||
|
|
1ab8aab43c |
@@ -34,6 +34,10 @@ vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplay
|
||||
vi.mock('./pages/exam', () => ({
|
||||
ExamDashboardPage: () => <div>Exam Marker</div>,
|
||||
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
|
||||
MarkSchemePage: () => <div>Mark Scheme editor</div>,
|
||||
ExamMarkingPage: () => <div>Exam Marking</div>,
|
||||
ExamResultsPage: () => <div>Exam Results</div>,
|
||||
ResultsWidget: () => <div>Results Widget</div>,
|
||||
}));
|
||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||
@@ -125,3 +129,22 @@ describe('/admin route authorization', () => {
|
||||
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('exam-marker routes', () => {
|
||||
beforeEach(() => {
|
||||
mockUseAuth.mockReset();
|
||||
});
|
||||
|
||||
it('renders the mark scheme editor route for authenticated users', () => {
|
||||
mockUseAuth.mockReturnValue(authState({
|
||||
user: { id: 'teacher-1', email: '[email protected]', user_type: 'email_teacher' },
|
||||
user_role: 'email_teacher',
|
||||
accessToken: 'token',
|
||||
}));
|
||||
|
||||
renderAt('/exam-marker/template-123/marks');
|
||||
|
||||
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+4
-1
@@ -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 } 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';
|
||||
@@ -184,6 +184,9 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:batchId/results" element={<ErrorBoundary><ExamResultsPage /></ErrorBoundary>} />
|
||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||
<Route path="/morphic" element={<MorphicPage />} />
|
||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||
|
||||
@@ -24,6 +24,7 @@ import ArchiveIcon from '@mui/icons-material/Archive';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import GradingIcon from '@mui/icons-material/Grading';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
@@ -320,6 +321,16 @@ const ExamDashboardPage: React.FC = () => {
|
||||
Updated {new Date(t.updated_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
||||
startIcon={<GradingIcon fontSize="small" />}
|
||||
>
|
||||
Edit marks
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -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<BatchQueueResponse | null>(null);
|
||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [marks, setMarks] = useState<Record<string, string>>({});
|
||||
const [comments, setComments] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (error && !queue) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
||||
Back to Exam Marker
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||
Exam Marker
|
||||
</Button>
|
||||
<Typography variant="h4" component="h1" fontWeight={700}>{queue?.batch.title || 'Mark exam'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" startIcon={<TableChartIcon />} onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
|
||||
Results
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||
{message && <Alert severity="success" onClose={() => setMessage(null)}>{message}</Alert>}
|
||||
{markableQuestions.length === 0 && (
|
||||
<Alert severity="warning">
|
||||
This template has no markable parts yet. Add parts in template setup before entering marks.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Marking queue</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
||||
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||
<Chip size="small" label={`${queue?.progress.complete ?? 0} complete`} color="success" variant="outlined" />
|
||||
</Stack>
|
||||
<List dense disablePadding>
|
||||
{(queue?.submissions ?? []).map((submission) => (
|
||||
<ListItemButton
|
||||
key={submission.id}
|
||||
selected={submission.id === selectedId}
|
||||
onClick={() => chooseStudent(submission)}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={submission.student_name || submission.student_id || 'Unknown student'}
|
||||
secondary={`${submission.status} · ${submission.mark_entry_count ?? 0} marks`}
|
||||
/>
|
||||
<Chip size="small" label={submission.status} color={submission.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" sx={{ flex: 1 }}>
|
||||
<CardContent>
|
||||
{selected ? (
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h6">{selected.student_name || selected.student_id || 'Unknown student'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Status: {selected.status}</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
{markableQuestions.map((q: ExamQuestion) => (
|
||||
<Stack key={q.id} direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||
<Box sx={{ minWidth: 150 }}>
|
||||
<Typography variant="body2" fontWeight={700}>{q.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">/ {q.max_marks} marks</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Mark"
|
||||
type="number"
|
||||
size="small"
|
||||
inputProps={{ min: 0, max: q.max_marks, step: 0.5 }}
|
||||
value={marks[q.id] ?? ''}
|
||||
onChange={(e) => setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
||||
sx={{ width: { xs: '100%', sm: 120 } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment"
|
||||
size="small"
|
||||
value={comments[q.id] ?? ''}
|
||||
onChange={(e) => setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button onClick={() => navigate(`/exam-marker/${batchId}/results`)}>Skip to results</Button>
|
||||
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
|
||||
{saving ? 'Saving…' : 'Save marks'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No submissions in this batch.</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamMarkingPage;
|
||||
@@ -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<BatchResultsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Alert severity="error">{error || 'Results not found'}</Alert>
|
||||
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
||||
Back to Exam Marker
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||
Exam Marker
|
||||
</Button>
|
||||
<Typography variant="h4" component="h1" fontWeight={700}>
|
||||
{data.batch.title || 'Exam results'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="outlined" startIcon={<EditIcon />} onClick={() => navigate(`/exam-marker/${data.batch.id}/mark`)}>
|
||||
Mark
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={downloading}>
|
||||
{downloading ? 'Preparing…' : 'Download CSV'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Chip label={`${summary.total} students`} />
|
||||
<Chip label={`${summary.marked} with marks`} color="success" variant="outlined" />
|
||||
<Chip label={`${summary.absent} absent/no scan`} color="warning" variant="outlined" />
|
||||
<Chip label={`Class average ${summary.average === null ? '—' : summary.average.toFixed(1)}`} color="primary" />
|
||||
</Stack>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Student</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
{data.questions.map((q) => (
|
||||
<TableCell key={q.id} align="right">{q.label} / {q.max_marks}</TableCell>
|
||||
))}
|
||||
<TableCell align="right">Total</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.results.map((row) => (
|
||||
<TableRow key={row.submission_id} sx={row.status === 'absent' && row.total === null ? { opacity: 0.72 } : undefined}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={600}>{row.student_name || row.student_id || 'Unknown student'}</Typography>
|
||||
{row.student_id && <Typography variant="caption" color="text.secondary">{row.student_id}</Typography>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={row.status || 'unknown'} color={row.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
||||
</TableCell>
|
||||
{data.questions.map((q) => (
|
||||
<TableCell key={q.id} align="right">{formatMark(row.marks[q.id])}</TableCell>
|
||||
))}
|
||||
<TableCell align="right"><strong>{formatMark(row.total)}</strong></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamResultsPage;
|
||||
@@ -0,0 +1,501 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import SyncIcon from '@mui/icons-material/Sync';
|
||||
|
||||
import { logger } from '../../debugConfig';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type {
|
||||
ExamQuestion,
|
||||
ExamTemplateDetail,
|
||||
MarkScheme,
|
||||
MarkSchemeType,
|
||||
SpecPoint,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
type Drafts = Record<string, PartDraft>;
|
||||
|
||||
interface PartDraft {
|
||||
max_marks: string;
|
||||
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
|
||||
spec_ref: string;
|
||||
schemeType: MarkSchemeType;
|
||||
body: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['written', 'short', 'mcq', 'diagram'];
|
||||
const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [
|
||||
{ value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' },
|
||||
{ value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' },
|
||||
{ value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' },
|
||||
{ value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' },
|
||||
{ value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' },
|
||||
];
|
||||
|
||||
function deriveSpecCode(template: ExamTemplateDetail | null): string {
|
||||
const examCode = template?.exam_code ?? '';
|
||||
const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i);
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
function schemeToDraft(scheme: MarkScheme | null | undefined): Pick<PartDraft, 'schemeType' | 'body' | 'notes'> {
|
||||
const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points';
|
||||
const notes = typeof scheme?.notes === 'string' ? scheme.notes : '';
|
||||
|
||||
if (type === 'levels' && Array.isArray(scheme?.levels)) {
|
||||
return {
|
||||
schemeType: 'levels',
|
||||
body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'parts' && Array.isArray(scheme?.parts)) {
|
||||
return {
|
||||
schemeType: 'parts',
|
||||
body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'checklist' && Array.isArray(scheme?.checklist)) {
|
||||
return {
|
||||
schemeType: 'checklist',
|
||||
body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'free') {
|
||||
return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes };
|
||||
}
|
||||
if (Array.isArray(scheme?.points)) {
|
||||
return {
|
||||
schemeType: 'points',
|
||||
body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
return { schemeType: type, body: '', notes };
|
||||
}
|
||||
|
||||
function parseNumber(value: string, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function splitLine(line: string): string[] {
|
||||
return line.split('|').map((part) => part.trim());
|
||||
}
|
||||
|
||||
function draftToScheme(draft: PartDraft): MarkScheme {
|
||||
const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined };
|
||||
if (draft.schemeType === 'levels') {
|
||||
return {
|
||||
type: 'levels',
|
||||
levels: lines.map((line, idx) => {
|
||||
const [level, range, descriptor] = splitLine(line);
|
||||
const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim());
|
||||
return {
|
||||
level: level || `L${idx + 1}`,
|
||||
min: parseNumber(minRaw, 0),
|
||||
max: parseNumber(maxRaw, parseNumber(minRaw, 0)),
|
||||
descriptor: descriptor || line,
|
||||
};
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
if (draft.schemeType === 'parts') {
|
||||
return {
|
||||
type: 'parts',
|
||||
parts: lines.map((line, idx) => {
|
||||
const [label, marks, guidance] = splitLine(line);
|
||||
return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
if (draft.schemeType === 'checklist') {
|
||||
return {
|
||||
type: 'checklist',
|
||||
checklist: lines.map((line) => {
|
||||
const [marks, text] = splitLine(line);
|
||||
return { marks: parseNumber(marks, 1), text: text || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'points',
|
||||
points: lines.map((line) => {
|
||||
const [mark, text] = splitLine(line);
|
||||
return { mark: parseNumber(mark, 1), text: text || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDraft(question: ExamQuestion): PartDraft {
|
||||
const scheme = schemeToDraft(question.mark_scheme);
|
||||
return {
|
||||
max_marks: String(question.max_marks ?? 0),
|
||||
answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written',
|
||||
spec_ref: question.spec_ref ?? '',
|
||||
schemeType: scheme.schemeType,
|
||||
body: scheme.body,
|
||||
notes: scheme.notes,
|
||||
};
|
||||
}
|
||||
|
||||
const MarkSchemePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { templateId } = useParams<{ templateId: string }>();
|
||||
|
||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Drafts>({});
|
||||
const [specCode, setSpecCode] = useState('');
|
||||
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [specEndpointMissing, setSpecEndpointMissing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!templateId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const detail = await examRepository.getTemplate(templateId);
|
||||
const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
||||
setTemplate(detail);
|
||||
setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null);
|
||||
setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)])));
|
||||
setSpecCode((prev) => prev || deriveSpecCode(detail));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [templateId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const loadSpecPoints = useCallback(async () => {
|
||||
if (!specCode.trim()) return;
|
||||
setSpecEndpointMissing(false);
|
||||
try {
|
||||
setSpecPoints(await examRepository.listSpecPoints(specCode.trim()));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode });
|
||||
setSpecEndpointMissing(true);
|
||||
setSpecPoints([]);
|
||||
}
|
||||
}, [specCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSpecPoints();
|
||||
}, [loadSpecPoints]);
|
||||
|
||||
const parts = useMemo(
|
||||
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
|
||||
[template],
|
||||
);
|
||||
const selected = parts.find((p) => p.id === selectedId) ?? null;
|
||||
const draft = selected ? drafts[selected.id] : null;
|
||||
const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null;
|
||||
|
||||
const updateDraft = (questionId: string, patch: Partial<PartDraft>) => {
|
||||
setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } }));
|
||||
};
|
||||
|
||||
const savePart = async (question: ExamQuestion) => {
|
||||
const partDraft = drafts[question.id];
|
||||
if (!partDraft) return;
|
||||
setSavingId(question.id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await examRepository.patchQuestion(question.id, {
|
||||
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||
answer_type: partDraft.answer_type,
|
||||
mark_scheme: draftToScheme(partDraft),
|
||||
spec_ref: partDraft.spec_ref.trim() || null,
|
||||
});
|
||||
setTemplate((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
questions: prev.questions.map((q) => q.id === question.id ? {
|
||||
...q,
|
||||
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||
answer_type: partDraft.answer_type,
|
||||
mark_scheme: draftToScheme(partDraft),
|
||||
spec_ref: partDraft.spec_ref.trim() || null,
|
||||
} : q),
|
||||
};
|
||||
});
|
||||
setNotice(`Saved mark scheme for Part ${question.label}.`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const syncGraph = async () => {
|
||||
if (!templateId) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await examRepository.syncTemplateToGraph(templateId);
|
||||
setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||
Back to templates
|
||||
</Button>
|
||||
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
|
||||
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
|
||||
{syncing ? 'Syncing…' : 'Sync to graph'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
|
||||
{specEndpointMissing && (
|
||||
<Alert severity="warning">
|
||||
SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label="Specification code"
|
||||
value={specCode}
|
||||
onChange={(e) => setSpecCode(e.target.value)}
|
||||
fullWidth
|
||||
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint).
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{parts.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
|
||||
</Alert>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Parts</Typography>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
value={selectedId ?? false}
|
||||
onChange={(_, value) => setSelectedId(value)}
|
||||
variant="scrollable"
|
||||
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
|
||||
>
|
||||
{parts.map((part) => (
|
||||
<Tab
|
||||
key={part.id}
|
||||
value={part.id}
|
||||
label={
|
||||
<Box sx={{ textAlign: 'left', width: '100%' }}>
|
||||
<Typography variant="body2">Part {part.label}</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
|
||||
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={9}>
|
||||
{selected && draft && (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography variant="h5">Part {selected.label}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={savingId === selected.id}
|
||||
onClick={() => savePart(selected)}
|
||||
>
|
||||
{savingId === selected.id ? 'Saving…' : 'Save part'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Max marks"
|
||||
value={draft.max_marks}
|
||||
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
|
||||
type="number"
|
||||
inputProps={{ min: 0, step: 0.5 }}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="answer-type-label">Answer type</InputLabel>
|
||||
<Select
|
||||
labelId="answer-type-label"
|
||||
label="Answer type"
|
||||
value={draft.answer_type}
|
||||
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
|
||||
>
|
||||
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
|
||||
<Select
|
||||
labelId="scheme-type-label"
|
||||
label="Scheme form"
|
||||
value={draft.schemeType}
|
||||
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
|
||||
>
|
||||
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Autocomplete
|
||||
options={specPoints}
|
||||
value={selectedSpecPoint}
|
||||
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
|
||||
getOptionLabel={(option) => `${option.ref} — ${option.description}`}
|
||||
isOptionEqualToValue={(option, value) => option.ref === value.ref}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="SpecPoint picker"
|
||||
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="spec_ref"
|
||||
value={draft.spec_ref}
|
||||
onChange={(e) => updateDraft(selected.id, { spec_ref: e.target.value })}
|
||||
helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint."
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TextField
|
||||
label={`${SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`}
|
||||
value={draft.body}
|
||||
onChange={(e) => updateDraft(selected.id, { body: e.target.value })}
|
||||
helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper}
|
||||
multiline
|
||||
minRows={10}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Internal marking notes"
|
||||
value={draft.notes}
|
||||
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkSchemePage;
|
||||
@@ -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<ResultsWidgetProps> = ({ classId, className }) => {
|
||||
const navigate = useNavigate();
|
||||
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
||||
const [batches, setBatches] = useState<MarkingBatch[]>([]);
|
||||
const [latestResults, setLatestResults] = useState<BatchResultsResponse | null>(null);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Card variant="outlined" sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AssessmentIcon color="primary" />
|
||||
<Box>
|
||||
<Typography variant="h6">Assessment results</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Last exam summary for this class</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{loading && <CircularProgress size={22} />}
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="warning" onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{latestBatch ? (
|
||||
<Stack spacing={1.5}>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={700}>{latestBatch.title || 'Exam batch'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(latestBatch.created_at).toLocaleDateString('en-GB')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Chip size="small" color="primary" label={`Average ${average === null ? '—' : average.toFixed(1)}`} />
|
||||
<Chip size="small" variant="outlined" label={`${latestResults?.results.length ?? 0} students`} />
|
||||
<Chip size="small" color="warning" variant="outlined" label={`${absent} absent`} />
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="contained" startIcon={<TableChartIcon />} onClick={() => navigate(`/exam-marker/${latestBatch.id}/results`)}>
|
||||
View results
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => navigate(`/exam-marker/${latestBatch.id}/mark`)}>
|
||||
Continue marking
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : !loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No exam batches have been created for this class yet.
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Template"
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||
disabled={!templates.length || creating}
|
||||
sx={{ minWidth: 260 }}
|
||||
>
|
||||
{templates.map((template) => (
|
||||
<MenuItem key={template.id} value={template.id}>{template.title}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={createBatch}
|
||||
disabled={!selectedTemplateId || creating}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create marking batch'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultsWidget;
|
||||
@@ -1,2 +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';
|
||||
|
||||
@@ -88,10 +88,15 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||
}
|
||||
}
|
||||
|
||||
function bringDomainShapesToFront(editor: Editor) {
|
||||
const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id)
|
||||
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
|
||||
}
|
||||
|
||||
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||
if (!models.length) return
|
||||
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||
if (existing.length) editor.deleteShapes(existing)
|
||||
if (!models.length) return
|
||||
editor.createShapes(models.map((m) => ({
|
||||
id: createShapeId(m.id),
|
||||
type: SHAPE_TYPES[m.kind],
|
||||
@@ -117,8 +122,7 @@ function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
||||
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
|
||||
} as any
|
||||
}))
|
||||
const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber))
|
||||
try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ }
|
||||
// z-order is enforced by the caller via bringDomainShapesToFront
|
||||
}
|
||||
|
||||
function seedGuide(editor: Editor) {
|
||||
@@ -170,7 +174,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
|
||||
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
|
||||
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
|
||||
try { ed.sendToBack([shapeId as any]) } catch { /* */ }
|
||||
bringDomainShapesToFront(ed)
|
||||
}
|
||||
}
|
||||
setPdfStatus('ready')
|
||||
@@ -188,6 +192,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
if (editor) {
|
||||
syncPdfPages(editor, pages)
|
||||
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||
bringDomainShapesToFront(editor)
|
||||
}
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
@@ -256,6 +261,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
||||
bringDomainShapesToFront(editor)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -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 = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ResultsWidget classId={cls.id} className={cls.name} />
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tab label={`Students (${cls.student_count})`} />
|
||||
|
||||
@@ -11,12 +11,20 @@ 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,
|
||||
TemplateReplacePayload,
|
||||
UpdateTemplateMetaPayload,
|
||||
} from '../../types/exam.types';
|
||||
@@ -184,6 +192,70 @@ export const examRepository = {
|
||||
const headers = await authHeaders();
|
||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
},
|
||||
|
||||
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
|
||||
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
|
||||
{ headers, params: search ? { q: search } : undefined },
|
||||
);
|
||||
if (Array.isArray(res.data)) return res.data;
|
||||
return res.data.points ?? [];
|
||||
},
|
||||
|
||||
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
|
||||
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<BatchQueueResponse> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getBatchCsv(batchId: string): Promise<string> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default examRepository;
|
||||
|
||||
+127
-1
@@ -31,6 +31,42 @@ export interface CreateTemplatePayload {
|
||||
institute_id?: string;
|
||||
}
|
||||
|
||||
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
|
||||
|
||||
export interface MarkSchemePoint {
|
||||
mark: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkSchemeLevel {
|
||||
level: string;
|
||||
min: number;
|
||||
max: number;
|
||||
descriptor: string;
|
||||
}
|
||||
|
||||
export interface MarkSchemePart {
|
||||
label: string;
|
||||
marks: number;
|
||||
guidance: string;
|
||||
}
|
||||
|
||||
export interface MarkSchemeChecklistItem {
|
||||
text: string;
|
||||
marks: number;
|
||||
}
|
||||
|
||||
export interface MarkScheme {
|
||||
type?: MarkSchemeType;
|
||||
points?: MarkSchemePoint[];
|
||||
levels?: MarkSchemeLevel[];
|
||||
parts?: MarkSchemePart[];
|
||||
checklist?: MarkSchemeChecklistItem[];
|
||||
text?: string;
|
||||
notes?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateMetaPayload {
|
||||
title?: string;
|
||||
subject?: string | null;
|
||||
@@ -48,7 +84,7 @@ export interface ExamQuestion {
|
||||
max_marks: number;
|
||||
answer_type: string | null;
|
||||
mcq_options: unknown | null;
|
||||
mark_scheme: Record<string, unknown>;
|
||||
mark_scheme: MarkScheme;
|
||||
is_container: boolean;
|
||||
spec_ref: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
@@ -140,3 +176,93 @@ export interface TemplateReplacePayload {
|
||||
confirmed?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PatchQuestionPayload {
|
||||
label?: string;
|
||||
order?: number;
|
||||
max_marks?: number;
|
||||
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||
mcq_options?: unknown;
|
||||
mark_scheme?: MarkScheme;
|
||||
is_container?: boolean;
|
||||
spec_ref?: string | null;
|
||||
}
|
||||
|
||||
export interface SpecPoint {
|
||||
uid?: string;
|
||||
uuid_string?: string;
|
||||
ref: string;
|
||||
description: string;
|
||||
spec_code: string;
|
||||
exam_board_code?: string;
|
||||
}
|
||||
|
||||
export interface Neo4jSyncResult {
|
||||
status: string;
|
||||
projection?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, number | null | undefined>;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export interface BatchResultsResponse {
|
||||
batch: MarkingBatch;
|
||||
questions: Array<Pick<ExamQuestion, 'id' | 'label' | 'max_marks' | 'order'>>;
|
||||
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<string, unknown>;
|
||||
annotation_shape_ids?: unknown;
|
||||
comment?: string;
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user