Compare commits
2 Commits
d47e0205c9
...
1bb2011e58
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bb2011e58 | |||
| f4be133069 |
@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
|
|||||||
import SignupPage from './pages/auth/signupPage';
|
import SignupPage from './pages/auth/signupPage';
|
||||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||||
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage } from './pages/exam';
|
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage, CorpusPage } from './pages/exam';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import CalendarPage from './pages/user/calendarPage';
|
import CalendarPage from './pages/user/calendarPage';
|
||||||
import SettingsPage from './pages/user/settingsPage';
|
import SettingsPage from './pages/user/settingsPage';
|
||||||
@ -185,6 +185,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||||
<Route path="/exam-marker/bank" element={<ErrorBoundary><QuestionBankPage /></ErrorBoundary>} />
|
<Route path="/exam-marker/bank" element={<ErrorBoundary><QuestionBankPage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/corpus" element={<ErrorBoundary><CorpusPage /></ErrorBoundary>} />
|
||||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></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/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
||||||
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
|
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
|
||||||
|
|||||||
156
src/pages/exam/CorpusPage.tsx
Normal file
156
src/pages/exam/CorpusPage.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, CircularProgress, Container,
|
||||||
|
Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
|
import RemoveIcon from '@mui/icons-material/Remove';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { CorpusResponse, CorpusSpec, CorpusDocType } from '../../types/exam.types';
|
||||||
|
|
||||||
|
const DOCS: CorpusDocType[] = ['QP', 'MS', 'ER'];
|
||||||
|
const DOC_LABEL: Record<CorpusDocType, string> = { QP: 'Question papers', MS: 'Mark schemes', ER: 'Examiner reports' };
|
||||||
|
const SCIENCES = new Set(['Biology', 'Chemistry', 'Physics']);
|
||||||
|
|
||||||
|
const CorpusPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [corpus, setCorpus] = useState<CorpusResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [board, setBoard] = useState<string | null>(null);
|
||||||
|
const [level, setLevel] = useState<string | null>(null);
|
||||||
|
const [subject, setSubject] = useState<string | null>(null);
|
||||||
|
const [sciencesOnly, setSciencesOnly] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
try { setCorpus(await examRepository.getCorpus()); }
|
||||||
|
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const allSpecs = useMemo<CorpusSpec[]>(
|
||||||
|
() => (corpus?.boards ?? []).flatMap((b) => b.specs),
|
||||||
|
[corpus],
|
||||||
|
);
|
||||||
|
const boards = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.board))).sort(), [allSpecs]);
|
||||||
|
const levels = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.level))).sort(), [allSpecs]);
|
||||||
|
const subjects = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.subject))).sort(), [allSpecs]);
|
||||||
|
|
||||||
|
const specs = useMemo(() => allSpecs.filter((s) =>
|
||||||
|
(!board || s.board === board) && (!level || s.level === level) && (!subject || s.subject === subject)
|
||||||
|
&& (!sciencesOnly || SCIENCES.has(s.subject))
|
||||||
|
).sort((a, b) => (a.board + a.level + a.subject + a.spec_code).localeCompare(b.board + b.level + b.subject + b.spec_code)),
|
||||||
|
[allSpecs, board, level, subject, sciencesOnly]);
|
||||||
|
|
||||||
|
const filteredTotals = useMemo(() => specs.reduce((acc, s) => {
|
||||||
|
acc.specs += 1; acc.papers += s.n_papers;
|
||||||
|
DOCS.forEach((d) => { acc[d] += s.counts[d] ?? 0; });
|
||||||
|
return acc;
|
||||||
|
}, { specs: 0, papers: 0, QP: 0, MS: 0, ER: 0 } as Record<string, number>), [specs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>Exam Marker</Button>
|
||||||
|
<Typography variant="h4" component="h1" fontWeight={700}>Exam bank</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
The collected past-paper corpus — question papers, mark schemes and examiner reports per specification.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary of the current selection */}
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
|
||||||
|
<Chip color="default" label={`${filteredTotals.specs} specifications`} />
|
||||||
|
<Chip color="default" label={`${filteredTotals.papers} papers`} />
|
||||||
|
<Chip color="primary" variant="outlined" label={`${filteredTotals.QP} question papers`} />
|
||||||
|
<Chip color="success" variant="outlined" label={`${filteredTotals.MS} mark schemes`} />
|
||||||
|
<Chip color="warning" variant="outlined" label={`${filteredTotals.ER} examiner reports`} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Stack spacing={0.75} sx={{ mb: 2 }}>
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip size="small" color={sciencesOnly ? 'secondary' : 'default'} variant={sciencesOnly ? 'filled' : 'outlined'} label="Sciences only" onClick={() => setSciencesOnly((v) => !v)} />
|
||||||
|
<Box sx={{ width: 12 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">Board:</Typography>
|
||||||
|
<Chip size="small" label="all" variant={board ? 'outlined' : 'filled'} color={board ? 'default' : 'primary'} onClick={() => setBoard(null)} />
|
||||||
|
{boards.map((b) => <Chip key={b} size="small" label={b} variant={board === b ? 'filled' : 'outlined'} color={board === b ? 'primary' : 'default'} onClick={() => setBoard(b)} />)}
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
|
<Typography variant="caption" color="text.secondary">Level:</Typography>
|
||||||
|
<Chip size="small" label="all" variant={level ? 'outlined' : 'filled'} color={level ? 'default' : 'primary'} onClick={() => setLevel(null)} />
|
||||||
|
{levels.map((l) => <Chip key={l} size="small" label={l} variant={level === l ? 'filled' : 'outlined'} color={level === l ? 'primary' : 'default'} onClick={() => setLevel(l)} />)}
|
||||||
|
{!sciencesOnly && (<>
|
||||||
|
<Box sx={{ width: 12 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">Subject:</Typography>
|
||||||
|
<Chip size="small" label="all" variant={subject ? 'outlined' : 'filled'} color={subject ? 'default' : 'primary'} onClick={() => setSubject(null)} />
|
||||||
|
{subjects.map((s) => <Chip key={s} size="small" label={s} variant={subject === s ? 'filled' : 'outlined'} color={subject === s ? 'primary' : 'default'} onClick={() => setSubject(s)} />)}
|
||||||
|
</>)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{specs.length === 0 ? (
|
||||||
|
<Alert severity="info">No specifications match the current filters.</Alert>
|
||||||
|
) : specs.map((s) => (
|
||||||
|
<Accordion key={s.spec_code} disableGutters>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', width: '100%' }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, minWidth: 260 }}>
|
||||||
|
{s.level} {s.subject} <Typography component="span" color="text.secondary" variant="body2">· {s.spec_code}</Typography>
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" label={`${s.n_papers} papers`} />
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
{DOCS.map((d) => (
|
||||||
|
<Chip key={d} size="small" variant="outlined"
|
||||||
|
color={s.counts[d] ? (d === 'QP' ? 'primary' : d === 'MS' ? 'success' : 'warning') : 'default'}
|
||||||
|
label={`${d} ${s.counts[d] ?? 0}`} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ p: 0 }}>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Session</TableCell>
|
||||||
|
<TableCell>Paper</TableCell>
|
||||||
|
<TableCell>Tier</TableCell>
|
||||||
|
{DOCS.map((d) => <TableCell key={d} align="center">{DOC_LABEL[d]}</TableCell>)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{s.papers.map((p, i) => (
|
||||||
|
<TableRow key={`${p.paper_code}-${p.session}-${i}`} hover>
|
||||||
|
<TableCell>{p.session ?? '—'}</TableCell>
|
||||||
|
<TableCell>{p.paper_code ?? '—'}</TableCell>
|
||||||
|
<TableCell>{p.tier ?? '—'}</TableCell>
|
||||||
|
{DOCS.map((d) => (
|
||||||
|
<TableCell key={d} align="center">
|
||||||
|
{p.docs[d] ? <CheckIcon fontSize="small" color="success" /> : <RemoveIcon fontSize="small" color="disabled" />}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CorpusPage;
|
||||||
@ -26,6 +26,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import GradingIcon from '@mui/icons-material/Grading';
|
import GradingIcon from '@mui/icons-material/Grading';
|
||||||
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||||
|
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||||
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
@ -235,6 +236,9 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button variant="outlined" startIcon={<LibraryBooksIcon />} onClick={() => navigate('/exam-marker/corpus')}>
|
||||||
|
Exam bank
|
||||||
|
</Button>
|
||||||
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
|
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
|
||||||
Question bank
|
Question bank
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
export { default as QuestionBankPage } from './QuestionBankPage';
|
export { default as QuestionBankPage } from './QuestionBankPage';
|
||||||
|
export { default as CorpusPage } from './CorpusPage';
|
||||||
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||||
export { default as MarkSchemePage } from './MarkSchemePage';
|
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||||
export { default as ExamMarkingPage } from './ExamMarkingPage';
|
export { default as ExamMarkingPage } from './ExamMarkingPage';
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import type {
|
|||||||
AutoMapJobStatus,
|
AutoMapJobStatus,
|
||||||
AutoMapResponse,
|
AutoMapResponse,
|
||||||
BankResponse,
|
BankResponse,
|
||||||
|
CorpusResponse,
|
||||||
BatchQueueResponse,
|
BatchQueueResponse,
|
||||||
BatchResultsResponse,
|
BatchResultsResponse,
|
||||||
CreateBatchPayload,
|
CreateBatchPayload,
|
||||||
@ -303,6 +304,12 @@ export const examRepository = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getCorpus(): Promise<CorpusResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<CorpusResponse>(`${EXAM_BASE}/corpus`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
async getBank(params: { specRef?: string; subject?: string } = {}): Promise<BankResponse> {
|
async getBank(params: { specRef?: string; subject?: string } = {}): Promise<BankResponse> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
const res = await axios.get<BankResponse>(`${EXAM_BASE}/bank`, {
|
const res = await axios.get<BankResponse>(`${EXAM_BASE}/bank`, {
|
||||||
|
|||||||
@ -371,3 +371,28 @@ export interface CreateCustomPaperResult {
|
|||||||
n_questions: number;
|
n_questions: number;
|
||||||
n_response_areas: number;
|
n_response_areas: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Exam-bank corpus coverage (state of the collected bank) ───────────────────
|
||||||
|
export type CorpusDocType = 'QP' | 'MS' | 'ER';
|
||||||
|
export interface CorpusPaper {
|
||||||
|
paper_code: string | null;
|
||||||
|
session: string | null;
|
||||||
|
tier: string | null;
|
||||||
|
docs: Partial<Record<CorpusDocType, boolean>>;
|
||||||
|
exam_codes: Partial<Record<CorpusDocType, string>>;
|
||||||
|
}
|
||||||
|
export interface CorpusSpec {
|
||||||
|
spec_code: string;
|
||||||
|
subject: string;
|
||||||
|
level: string;
|
||||||
|
board: string;
|
||||||
|
first_teach: string | null;
|
||||||
|
n_papers: number;
|
||||||
|
counts: Record<CorpusDocType, number>;
|
||||||
|
papers: CorpusPaper[];
|
||||||
|
}
|
||||||
|
export interface CorpusBoard { board: string; n_specs: number; specs: CorpusSpec[] }
|
||||||
|
export interface CorpusResponse {
|
||||||
|
totals: { specs: number; papers: number; sessions: number; QP: number; MS: number; ER: number };
|
||||||
|
boards: CorpusBoard[];
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user