diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index f9139a2..807db01 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, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam';
+import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
@@ -184,6 +184,7 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx
index 3595c31..b81c819 100644
--- a/src/pages/exam/ExamDashboardPage.tsx
+++ b/src/pages/exam/ExamDashboardPage.tsx
@@ -25,6 +25,7 @@ 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 PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository';
@@ -233,9 +234,14 @@ const ExamDashboardPage: React.FC = () => {
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
- } onClick={openCreate}>
- New template
-
+
+ } onClick={() => navigate('/exam-marker/bank')}>
+ Question bank
+
+ } onClick={openCreate}>
+ New template
+
+
{error && (
diff --git a/src/pages/exam/QuestionBankPage.tsx b/src/pages/exam/QuestionBankPage.tsx
new file mode 100644
index 0000000..10f6bd8
--- /dev/null
+++ b/src/pages/exam/QuestionBankPage.tsx
@@ -0,0 +1,185 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Alert, Box, Button, Card, CardActionArea, CardContent, Chip, CircularProgress, Container,
+ Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography,
+} from '@mui/material';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
+import CheckCircleIcon from '@mui/icons-material/CheckCircle';
+import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
+
+import { examRepository } from '../../services/exam/examRepository';
+import type { BankQuestion, BankResponse } from '../../types/exam.types';
+
+const QuestionBankPage: React.FC = () => {
+ const navigate = useNavigate();
+ const [bank, setBank] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [specRef, setSpecRef] = useState(null);
+ const [subject, setSubject] = useState(null);
+ const [selected, setSelected] = useState>(new Set());
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [title, setTitle] = useState('');
+ const [creating, setCreating] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true); setError(null);
+ try {
+ setBank(await examRepository.getBank({ specRef: specRef ?? undefined, subject: subject ?? undefined }));
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, [specRef, subject]);
+
+ useEffect(() => { void load(); }, [load]);
+
+ const toggle = (id: string) => setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+
+ const selectedList = useMemo(() => Array.from(selected), [selected]);
+ const selectedQuestions = useMemo(
+ () => (bank?.questions ?? []).filter((q) => selected.has(q.id)),
+ [bank, selected],
+ );
+ const totalMarks = selectedQuestions.reduce((s, q) => s + (q.max_marks ?? 0), 0);
+
+ // facet lists (sorted) with counts
+ const specFacets = Object.entries(bank?.facets.spec_ref ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
+ const subjectFacets = Object.entries(bank?.facets.subject ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
+
+ const create = async () => {
+ setCreating(true); setError(null);
+ try {
+ const subj = subject ?? selectedQuestions[0]?.paper.subject ?? undefined;
+ const res = await examRepository.createCustomPaper({
+ title: title.trim() || `Custom paper — ${new Date().toISOString().slice(0, 10)}`,
+ subject: subj ?? undefined,
+ question_ids: selectedList,
+ });
+ navigate(`/exam-marker/${res.id}/setup`);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ setCreating(false);
+ setDialogOpen(false);
+ }
+ };
+
+ return (
+
+ } onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
+ Exam Marker
+
+
+
+ Question bank
+
+ Plan by spec point, then assemble a custom paper from questions across your papers.
+
+
+ }
+ disabled={selected.size === 0}
+ onClick={() => setDialogOpen(true)}
+ >
+ Create paper ({selected.size}{totalMarks ? ` · ${totalMarks}m` : ''})
+
+
+
+ {error && setError(null)}>{error}}
+
+ {/* Filters */}
+
+
+ Spec point:
+ setSpecRef(null)} variant={specRef ? 'outlined' : 'filled'} />
+ {specFacets.map(([ref, count]) => (
+ setSpecRef(ref)} />
+ ))}
+
+ {subjectFacets.length > 1 && (
+
+ Subject:
+ setSubject(null)} variant={subject ? 'outlined' : 'filled'} />
+ {subjectFacets.map(([s, count]) => (
+ setSubject(s)} sx={{ textTransform: 'capitalize' }} />
+ ))}
+
+ )}
+
+
+ {loading ? (
+
+ ) : (bank?.questions.length ?? 0) === 0 ? (
+
+ No questions match yet. Questions appear here once your templates have parts with spec points
+ (set them in the mark-scheme editor). Auto-map + confirm on a paper to populate it.
+
+ ) : (
+ <>
+
+ {bank?.n} question{bank?.n === 1 ? '' : 's'} · click to add to your paper
+
+
+ {(bank?.questions ?? []).map((q: BankQuestion) => {
+ const on = selected.has(q.id);
+ return (
+
+ toggle(q.id)}>
+
+
+ Q{q.label ?? '?'}
+ {on ? : }
+
+
+
+ {q.answer_type && }
+ {q.spec_ref && }
+
+
+ {[q.paper.subject, q.paper.title].filter(Boolean).join(' · ') || 'Untitled paper'}
+
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default QuestionBankPage;
diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts
index 21f10c6..3489d49 100644
--- a/src/pages/exam/index.ts
+++ b/src/pages/exam/index.ts
@@ -1,4 +1,5 @@
export { default as ExamDashboardPage } from './ExamDashboardPage';
+export { default as QuestionBankPage } from './QuestionBankPage';
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
export { default as MarkSchemePage } from './MarkSchemePage';
export { default as ExamMarkingPage } from './ExamMarkingPage';
diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts
index d3bd2d6..6c5daef 100644
--- a/src/services/exam/examRepository.ts
+++ b/src/services/exam/examRepository.ts
@@ -13,9 +13,12 @@ import { supabase } from '../../supabaseClient';
import type {
AutoMapJobStatus,
AutoMapResponse,
+ BankResponse,
BatchQueueResponse,
BatchResultsResponse,
CreateBatchPayload,
+ CreateCustomPaperPayload,
+ CreateCustomPaperResult,
CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
@@ -300,6 +303,21 @@ export const examRepository = {
return res.data;
},
+ async getBank(params: { specRef?: string; subject?: string } = {}): Promise {
+ const headers = await authHeaders();
+ const res = await axios.get(`${EXAM_BASE}/bank`, {
+ headers,
+ params: { spec_ref: params.specRef, subject: params.subject },
+ });
+ return res.data;
+ },
+
+ async createCustomPaper(payload: CreateCustomPaperPayload): Promise {
+ const headers = await authHeaders();
+ const res = await axios.post(`${EXAM_BASE}/custom-papers`, payload, { headers });
+ return res.data;
+ },
+
async uploadScan(
batchId: string,
file: File,
diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts
index b869de3..4c83a1e 100644
--- a/src/types/exam.types.ts
+++ b/src/types/exam.types.ts
@@ -338,3 +338,36 @@ export interface MarkUpsertPayload {
comment?: string;
confirmed?: boolean;
}
+
+// ── Mode-3 question bank + custom-paper assembly ──────────────────────────────
+export interface BankQuestion {
+ id: string;
+ template_id: string;
+ label: string | null;
+ max_marks: number | null;
+ answer_type: string | null;
+ spec_ref: string | null;
+ bounds: Record | null;
+ page: number | null;
+ paper: { id: string | null; title: string | null; subject: string | null; exam_code: string | null };
+}
+
+export interface BankResponse {
+ questions: BankQuestion[];
+ n: number;
+ facets: { spec_ref: Record; subject: Record };
+}
+
+export interface CreateCustomPaperPayload {
+ title: string;
+ subject?: string;
+ question_ids: string[];
+}
+
+export interface CreateCustomPaperResult {
+ id: string;
+ title: string;
+ subject: string | null;
+ n_questions: number;
+ n_response_areas: number;
+}