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. - + + + + {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 ( + + + + + Question bank + + Plan by spec point, then assemble a custom paper from questions across your papers. + + + + + + {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'} + + + + + ); + })} + + + )} + + !creating && setDialogOpen(false)} fullWidth maxWidth="sm"> + Create custom paper + + + {selected.size} question{selected.size === 1 ? '' : 's'} · {totalMarks} marks. This creates a new + template you can open in setup and use for marking. + + setTitle(e.target.value)} + placeholder={`Custom paper — ${new Date().toISOString().slice(0, 10)}`} + /> + + + + + + + + ); +}; + +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; +}