Compare commits

..

2 Commits

Author SHA1 Message Date
a80bdea140 Merge mode-3 question bank UI
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:09:28 +00:00
905626f494 Mode-3: Question bank page + custom-paper assembly UI
New /exam-marker/bank page: browse leaf questions across your papers, filter by
spec point (the spec-planning surface) and subject via facet chips, select
questions into a basket, and "Create paper" → POST /custom-papers → opens the new
template in setup. Dashboard gets a "Question bank" entry point.

examRepository.getBank + createCustomPaper; BankQuestion/BankResponse/custom-paper
types. tsc clean. Pairs with the mode-3 API endpoints (already merged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:09:28 +00:00
6 changed files with 248 additions and 4 deletions

View File

@ -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 = () => {
<Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
<Route path="/exam-marker/bank" element={<ErrorBoundary><QuestionBankPage /></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>} />

View File

@ -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.
</Typography>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template
</Button>
<Stack direction="row" spacing={1}>
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
Question bank
</Button>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template
</Button>
</Stack>
</Box>
{error && (

View File

@ -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<BankResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [specRef, setSpecRef] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(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 (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<Box>
<Typography variant="h4" component="h1" fontWeight={700}>Question bank</Typography>
<Typography variant="body2" color="text.secondary">
Plan by spec point, then assemble a custom paper from questions across your papers.
</Typography>
</Box>
<Button
variant="contained"
startIcon={<PlaylistAddCheckIcon />}
disabled={selected.size === 0}
onClick={() => setDialogOpen(true)}
>
Create paper ({selected.size}{totalMarks ? ` · ${totalMarks}m` : ''})
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{/* Filters */}
<Stack spacing={1} sx={{ mb: 2 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Spec point:</Typography>
<Chip size="small" label="all" color={specRef ? 'default' : 'primary'} onClick={() => setSpecRef(null)} variant={specRef ? 'outlined' : 'filled'} />
{specFacets.map(([ref, count]) => (
<Chip key={ref} size="small" label={`${ref} · ${count}`} color={specRef === ref ? 'primary' : 'default'} variant={specRef === ref ? 'filled' : 'outlined'} onClick={() => setSpecRef(ref)} />
))}
</Stack>
{subjectFacets.length > 1 && (
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Subject:</Typography>
<Chip size="small" label="all" color={subject ? 'default' : 'secondary'} onClick={() => setSubject(null)} variant={subject ? 'outlined' : 'filled'} />
{subjectFacets.map(([s, count]) => (
<Chip key={s} size="small" label={`${s} · ${count}`} color={subject === s ? 'secondary' : 'default'} variant={subject === s ? 'filled' : 'outlined'} onClick={() => setSubject(s)} sx={{ textTransform: 'capitalize' }} />
))}
</Stack>
)}
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : (bank?.questions.length ?? 0) === 0 ? (
<Alert severity="info">
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.
</Alert>
) : (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{bank?.n} question{bank?.n === 1 ? '' : 's'} · click to add to your paper
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gap: 1.5 }}>
{(bank?.questions ?? []).map((q: BankQuestion) => {
const on = selected.has(q.id);
return (
<Card key={q.id} variant="outlined" sx={{ borderColor: on ? 'primary.main' : 'divider', borderWidth: on ? 2 : 1 }}>
<CardActionArea onClick={() => toggle(q.id)}>
<CardContent sx={{ pb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" fontWeight={700}>Q{q.label ?? '?'}</Typography>
{on ? <CheckCircleIcon color="primary" fontSize="small" /> : <RadioButtonUncheckedIcon color="disabled" fontSize="small" />}
</Box>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 0.75 }}>
<Chip size="small" label={`${q.max_marks ?? 0} marks`} />
{q.answer_type && <Chip size="small" variant="outlined" label={q.answer_type} />}
{q.spec_ref && <Chip size="small" color="info" label={q.spec_ref} />}
</Stack>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', textTransform: 'capitalize' }}>
{[q.paper.subject, q.paper.title].filter(Boolean).join(' · ') || 'Untitled paper'}
</Typography>
</CardContent>
</CardActionArea>
</Card>
);
})}
</Box>
</>
)}
<Dialog open={dialogOpen} onClose={() => !creating && setDialogOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Create custom paper</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{selected.size} question{selected.size === 1 ? '' : 's'} · {totalMarks} marks. This creates a new
template you can open in setup and use for marking.
</Typography>
<TextField
autoFocus
fullWidth
label="Paper title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={`Custom paper — ${new Date().toISOString().slice(0, 10)}`}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} disabled={creating}>Cancel</Button>
<Button variant="contained" onClick={create} disabled={creating} startIcon={creating ? <CircularProgress size={16} color="inherit" /> : undefined}>
{creating ? 'Creating…' : 'Create & open'}
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default QuestionBankPage;

View File

@ -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';

View File

@ -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<BankResponse> {
const headers = await authHeaders();
const res = await axios.get<BankResponse>(`${EXAM_BASE}/bank`, {
headers,
params: { spec_ref: params.specRef, subject: params.subject },
});
return res.data;
},
async createCustomPaper(payload: CreateCustomPaperPayload): Promise<CreateCustomPaperResult> {
const headers = await authHeaders();
const res = await axios.post<CreateCustomPaperResult>(`${EXAM_BASE}/custom-papers`, payload, { headers });
return res.data;
},
async uploadScan(
batchId: string,
file: File,

View File

@ -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<string, number> | 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<string, number>; subject: Record<string, number> };
}
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;
}