Compare commits
No commits in common. "a80bdea140815847e227ea4f134c1291577504c7" and "b52f6c54ba7b4f6aa5e301532b3d23a0a405eaac" have entirely different histories.
a80bdea140
...
b52f6c54ba
@ -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 } 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';
|
||||||
@ -184,7 +184,6 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/search" element={<SearxngPage />} />
|
<Route path="/search" element={<SearxngPage />} />
|
||||||
<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/: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>} />
|
||||||
|
|||||||
@ -25,7 +25,6 @@ import AssignmentIcon from '@mui/icons-material/Assignment';
|
|||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
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 { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
@ -234,14 +233,9 @@ 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.
|
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row" spacing={1}>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
|
New template
|
||||||
Question bank
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
|
||||||
New template
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@ -1,185 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
export { default as QuestionBankPage } from './QuestionBankPage';
|
|
||||||
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';
|
||||||
|
|||||||
@ -13,12 +13,9 @@ import { supabase } from '../../supabaseClient';
|
|||||||
import type {
|
import type {
|
||||||
AutoMapJobStatus,
|
AutoMapJobStatus,
|
||||||
AutoMapResponse,
|
AutoMapResponse,
|
||||||
BankResponse,
|
|
||||||
BatchQueueResponse,
|
BatchQueueResponse,
|
||||||
BatchResultsResponse,
|
BatchResultsResponse,
|
||||||
CreateBatchPayload,
|
CreateBatchPayload,
|
||||||
CreateCustomPaperPayload,
|
|
||||||
CreateCustomPaperResult,
|
|
||||||
CreateTemplatePayload,
|
CreateTemplatePayload,
|
||||||
ExamBoundary,
|
ExamBoundary,
|
||||||
ExamQuestion,
|
ExamQuestion,
|
||||||
@ -303,21 +300,6 @@ export const examRepository = {
|
|||||||
return res.data;
|
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(
|
async uploadScan(
|
||||||
batchId: string,
|
batchId: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
|||||||
@ -338,36 +338,3 @@ export interface MarkUpsertPayload {
|
|||||||
comment?: string;
|
comment?: string;
|
||||||
confirmed?: boolean;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user