Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb2011e58 | ||
|
|
f4be133069 | ||
|
|
d47e0205c9 | ||
|
|
11d19778d6 | ||
|
|
a80bdea140 | ||
|
|
905626f494 | ||
|
|
b52f6c54ba | ||
|
|
e6a2480818 | ||
|
|
2b33aa80eb | ||
|
|
7a1dd4dccc | ||
|
|
f72ea7189c |
+3
-1
@@ -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 } 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';
|
||||||
@@ -184,6 +184,8 @@ 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/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>} />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -25,6 +25,8 @@ 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 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';
|
||||||
@@ -233,9 +235,17 @@ 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>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
<Stack direction="row" spacing={1}>
|
||||||
New template
|
<Button variant="outlined" startIcon={<LibraryBooksIcon />} onClick={() => navigate('/exam-marker/corpus')}>
|
||||||
</Button>
|
Exam bank
|
||||||
|
</Button>
|
||||||
|
<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>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { v5 as uuidv5 } from 'uuid';
|
import { v5 as uuidv5 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||||
@@ -43,6 +44,8 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!batchId) return;
|
if (!batchId) return;
|
||||||
@@ -68,6 +71,28 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
||||||
[template],
|
[template],
|
||||||
);
|
);
|
||||||
|
const onScanFiles = useCallback(async (files: FileList | null) => {
|
||||||
|
if (!batchId || !files || files.length === 0) return;
|
||||||
|
setUploading(true); setError(null); setMessage(null);
|
||||||
|
let ok = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
// 'ordered' assigns each scan to the next not-yet-submitted roster slot; unmatched scans
|
||||||
|
// become an unmatched submission the teacher can reconcile.
|
||||||
|
await examRepository.uploadScan(batchId, file, { matchingMethod: 'ordered' });
|
||||||
|
ok += 1;
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
await load();
|
||||||
|
if (ok) setMessage(`Uploaded ${ok} scan${ok === 1 ? '' : 's'}${failures.length ? `, ${failures.length} failed` : ''}`);
|
||||||
|
if (failures.length) setError(failures.join(' · '));
|
||||||
|
}, [batchId, load]);
|
||||||
|
|
||||||
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
||||||
|
|
||||||
const saveSelected = async () => {
|
const saveSelected = async () => {
|
||||||
@@ -154,7 +179,29 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||||
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>Marking queue</Typography>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
|
||||||
|
<Typography variant="h6">Marking queue</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFileIcon fontSize="small" />}
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : 'Upload scans'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => { void onScanFiles(e.target.files); }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
|
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
|
||||||
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
||||||
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
||||||
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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';
|
||||||
|
import { cropQuestion } from './bankCrops';
|
||||||
|
|
||||||
|
const QuestionCrop: React.FC<{ q: BankQuestion }> = ({ q }) => {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
cropQuestion({ templateId: q.template_id, page: q.page, bounds: q.bounds })
|
||||||
|
.then((d) => { if (alive) { setSrc(d); setDone(true); } })
|
||||||
|
.catch(() => { if (alive) setDone(true); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [q.template_id, q.page, q.bounds]);
|
||||||
|
if (src) {
|
||||||
|
return <Box component="img" src={src} alt="" loading="lazy" sx={{ width: '100%', maxHeight: 150, objectFit: 'cover', objectPosition: 'top', display: 'block', bgcolor: '#fff', borderBottom: 1, borderColor: 'divider' }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: 1, borderColor: 'divider', color: 'text.disabled', fontSize: 12 }}>
|
||||||
|
{done ? 'no preview' : <CircularProgress size={16} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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)}>
|
||||||
|
<QuestionCrop q={q} />
|
||||||
|
<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;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Client-side crop thumbnails for the question bank. A bank question carries `bounds` (x,y,w,h) in the
|
||||||
|
// 780px stacked-page canvas space and a `page` number; the source PDF renders at that same 780px width
|
||||||
|
// (pdfLoader), so a thumbnail is just the page image cropped to the bounds. Renders each template's PDF
|
||||||
|
// once (cached) and reuses it for all of that template's questions. No API/infra change.
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import { loadPdfPageImages, PdfPageImage } from './setup/pdfLoader';
|
||||||
|
|
||||||
|
const renderCache = new Map<string, Promise<PdfPageImage[]>>();
|
||||||
|
const imageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||||
|
|
||||||
|
function templateRender(templateId: string): Promise<PdfPageImage[]> {
|
||||||
|
let p = renderCache.get(templateId);
|
||||||
|
if (!p) {
|
||||||
|
p = examRepository.getTemplateSourcePdf(templateId).then((bytes) => loadPdfPageImages(bytes));
|
||||||
|
p.catch(() => renderCache.delete(templateId)); // let a failed render be retried
|
||||||
|
renderCache.set(templateId, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
let p = imageCache.get(src);
|
||||||
|
if (!p) {
|
||||||
|
p = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('image decode failed'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
imageCache.set(src, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CropInput {
|
||||||
|
templateId: string;
|
||||||
|
page: number | null;
|
||||||
|
bounds: Record<string, number> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a PNG data-URL cropped to the question, or null if it can't be produced (no bounds/page/PDF).
|
||||||
|
export async function cropQuestion(input: CropInput, maxWidth = 320): Promise<string | null> {
|
||||||
|
const { templateId, page, bounds } = input;
|
||||||
|
if (!bounds || !page) return null;
|
||||||
|
const pages = await templateRender(templateId);
|
||||||
|
const pageImg = pages.find((pg) => pg.pageNumber === page);
|
||||||
|
if (!pageImg) return null;
|
||||||
|
|
||||||
|
// stacked y-offset of this page (bounds.y is absolute across the vertically-stacked pages)
|
||||||
|
let yTop = 0;
|
||||||
|
for (const pg of pages) { if (pg.pageNumber === page) break; yTop += pg.height; }
|
||||||
|
|
||||||
|
const sx = Math.max(0, Math.round(bounds.x ?? 0));
|
||||||
|
const sy = Math.max(0, Math.round((bounds.y ?? 0) - yTop));
|
||||||
|
const sw = Math.min(Math.round(bounds.w ?? pageImg.width), pageImg.width - sx);
|
||||||
|
const sh = Math.min(Math.round(bounds.h ?? 80), pageImg.height - sy);
|
||||||
|
if (sw <= 1 || sh <= 1) return null;
|
||||||
|
|
||||||
|
const img = await loadImage(pageImg.src);
|
||||||
|
const scale = Math.min(1, maxWidth / sw);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(sw * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(sh * scale));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
|
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';
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
|||||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
||||||
import SaveIcon from '@mui/icons-material/Save'
|
import SaveIcon from '@mui/icons-material/Save'
|
||||||
import MouseIcon from '@mui/icons-material/Mouse'
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
|
import CheckIcon from '@mui/icons-material/Check'
|
||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import DoneAllIcon from '@mui/icons-material/DoneAll'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
@@ -82,6 +85,21 @@ function reviewSummary(template: ExamTemplateDetail | null) {
|
|||||||
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live review counts read straight off the canvas shapes, so accept/reject update them immediately
|
||||||
|
// (the template-derived reviewSummary only reflects the last *saved* state).
|
||||||
|
function reviewFromShapes(editor: Editor | null) {
|
||||||
|
const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 }
|
||||||
|
if (!editor) return acc
|
||||||
|
for (const shape of editor.getCurrentPageShapes()) {
|
||||||
|
if (!shapeTypeToKind(shape.type)) continue
|
||||||
|
const p = (shape as ExamCanvasTLShape).props
|
||||||
|
if (p.source === 'ai') acc.ai += 1
|
||||||
|
if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1
|
||||||
|
if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
function stripShapePrefix(id: string) {
|
function stripShapePrefix(id: string) {
|
||||||
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||||
}
|
}
|
||||||
@@ -205,8 +223,11 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const [guideOpen, setGuideOpen] = useState(false)
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
||||||
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
||||||
|
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||||
const autoMapPollRef = useRef<number | null>(null)
|
const autoMapPollRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
|
||||||
|
|
||||||
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
||||||
setTemplate(detail)
|
setTemplate(detail)
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
@@ -219,7 +240,33 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
setDirty(false)
|
setDirty(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const review = useMemo(() => reviewSummary(template), [template])
|
const acceptShapes = useCallback((shapes: TLShape[]) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const updates = shapes
|
||||||
|
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
|
||||||
|
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
|
||||||
|
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
|
||||||
|
}, [refreshReview])
|
||||||
|
|
||||||
|
const selectedDomainShapes = useCallback((): TLShape[] => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return []
|
||||||
|
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
|
||||||
|
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
|
||||||
|
const rejectSelected = useCallback(() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const ids = selectedDomainShapes().map((s) => s.id)
|
||||||
|
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
|
||||||
|
}, [refreshReview, selectedDomainShapes])
|
||||||
|
|
||||||
|
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
|
||||||
|
// saved-template summary only for the brief moment before shapes have mounted).
|
||||||
|
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
@@ -416,7 +463,23 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
editorRef.current = editor
|
editorRef.current = editor
|
||||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' })
|
||||||
|
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
|
||||||
|
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
|
||||||
|
// update below can't recurse.
|
||||||
|
editor.store.listen((entry) => {
|
||||||
|
try {
|
||||||
|
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
|
||||||
|
for (const rec of Object.values(entry.changes.updated)) {
|
||||||
|
const [from, to] = rec as [any, any]
|
||||||
|
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
|
||||||
|
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
|
||||||
|
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
|
||||||
|
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
|
||||||
|
}
|
||||||
|
if (updates.length) editor.updateShapes(updates as any)
|
||||||
|
} catch { /* non-fatal: implicit-confirm is best-effort */ }
|
||||||
|
}, { scope: 'document', source: 'user' })
|
||||||
applyDocViewConstraints(editor, [])
|
applyDocViewConstraints(editor, [])
|
||||||
editor.resetZoom()
|
editor.resetZoom()
|
||||||
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
||||||
@@ -432,6 +495,22 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* AI review actions — appears while there are unconfirmed suggestions to resolve */}
|
||||||
|
{review.unconfirmed > 0 && (
|
||||||
|
<Paper elevation={6} sx={{ position: 'absolute', left: 16, bottom: 16, zIndex: 1002, px: 1, py: 0.75, borderRadius: 2, bgcolor: 'background.paper', display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Chip size="small" color="warning" label={`${review.unconfirmed} AI unconfirmed`} sx={{ fontWeight: 700 }} />
|
||||||
|
<Tooltip title="Accept the selected AI suggestions (nudging one also confirms it)">
|
||||||
|
<span><Button size="small" color="success" startIcon={<CheckIcon fontSize="small" />} onClick={acceptSelected}>Accept</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Reject (delete) the selected shapes">
|
||||||
|
<span><Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} onClick={rejectSelected}>Reject</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Accept every AI suggestion on the page">
|
||||||
|
<span><Button size="small" variant="contained" startIcon={<DoneAllIcon fontSize="small" />} onClick={acceptAllAi}>Accept all</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Guide toggle */}
|
{/* Guide toggle */}
|
||||||
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
||||||
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ function confidenceLabel(confidence: number | null | undefined) {
|
|||||||
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
|
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid.
|
||||||
|
function ghostOpacity(confidence: number | null | undefined) {
|
||||||
|
if (typeof confidence !== 'number') return 0.72
|
||||||
|
return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence))
|
||||||
|
}
|
||||||
|
|
||||||
function reviewFlags(shape: ExamCanvasTLShape): string[] {
|
function reviewFlags(shape: ExamCanvasTLShape): string[] {
|
||||||
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
@@ -100,16 +106,17 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
|||||||
const p = canvasShapePalette.boundary
|
const p = canvasShapePalette.boundary
|
||||||
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
||||||
const isAi = shape.props.source === 'ai'
|
const isAi = shape.props.source === 'ai'
|
||||||
|
const isGhost = isAi && shape.props.confirmed === false
|
||||||
const confidence = confidenceLabel(shape.props.confidence)
|
const confidence = confidenceLabel(shape.props.confidence)
|
||||||
const flags = reviewFlags(shape)
|
const flags = reviewFlags(shape)
|
||||||
return (
|
return (
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
||||||
<style>{shapeCss}</style>
|
<style>{shapeCss}</style>
|
||||||
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||||
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isAi ? '4 6' : p.dash} strokeLinecap="round" opacity={isAi ? 0.62 : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
||||||
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
<span aria-hidden="true">{isGhost ? 'AI' : p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</span>
|
||||||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
@@ -139,8 +146,8 @@ function renderShape(shape: ExamCanvasTLShape) {
|
|||||||
'--exam-dark-stroke': p.darkStroke,
|
'--exam-dark-stroke': p.darkStroke,
|
||||||
'--exam-dark-fill': p.darkFill,
|
'--exam-dark-fill': p.darkFill,
|
||||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||||
borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
@@ -148,7 +155,7 @@ function renderShape(shape: ExamCanvasTLShape) {
|
|||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
<span aria-hidden="true">{isAiSuggestion ? 'AI' : p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</span>
|
||||||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ import { supabase } from '../../supabaseClient';
|
|||||||
import type {
|
import type {
|
||||||
AutoMapJobStatus,
|
AutoMapJobStatus,
|
||||||
AutoMapResponse,
|
AutoMapResponse,
|
||||||
|
BankResponse,
|
||||||
|
CorpusResponse,
|
||||||
BatchQueueResponse,
|
BatchQueueResponse,
|
||||||
BatchResultsResponse,
|
BatchResultsResponse,
|
||||||
CreateBatchPayload,
|
CreateBatchPayload,
|
||||||
|
CreateCustomPaperPayload,
|
||||||
|
CreateCustomPaperResult,
|
||||||
CreateTemplatePayload,
|
CreateTemplatePayload,
|
||||||
ExamBoundary,
|
ExamBoundary,
|
||||||
ExamQuestion,
|
ExamQuestion,
|
||||||
@@ -28,6 +32,7 @@ import type {
|
|||||||
Neo4jSyncResult,
|
Neo4jSyncResult,
|
||||||
PatchQuestionPayload,
|
PatchQuestionPayload,
|
||||||
SpecPoint,
|
SpecPoint,
|
||||||
|
StudentSubmission,
|
||||||
TemplateReplacePayload,
|
TemplateReplacePayload,
|
||||||
UpdateTemplateMetaPayload,
|
UpdateTemplateMetaPayload,
|
||||||
} from '../../types/exam.types';
|
} from '../../types/exam.types';
|
||||||
@@ -298,6 +303,42 @@ export const examRepository = {
|
|||||||
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||||
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> {
|
||||||
|
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,
|
||||||
|
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
|
||||||
|
): Promise<StudentSubmission> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('matching_method', opts.matchingMethod ?? 'ordered');
|
||||||
|
if (opts.studentId) form.append('student_id', opts.studentId);
|
||||||
|
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
|
||||||
|
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default examRepository;
|
export default examRepository;
|
||||||
|
|||||||
@@ -338,3 +338,61 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user