Compare commits

..
Author SHA1 Message Date
kcar c6b459a6ca chore: preserve app workspace t_9be330a5 2026-08-09 20:24:44 +01:00
17 changed files with 358 additions and 2030 deletions
-3
View File
@@ -19,9 +19,6 @@ FROM nginx:alpine
# Copy built files # Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# .mjs files (pdfjs worker) must be served as application/javascript for module workers
RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
# Create a simple nginx configuration # Create a simple nginx configuration
RUN echo 'server { \ RUN echo 'server { \
listen 3000; \ listen 3000; \
+1 -27
View File
@@ -31,14 +31,7 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> })); vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> })); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> })); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
vi.mock('./pages/exam', () => ({ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
ExamDashboardPage: () => <div>Exam Marker</div>,
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
MarkSchemePage: () => <div>Mark Scheme editor</div>,
ExamMarkingPage: () => <div>Exam Marking</div>,
ExamResultsPage: () => <div>Exam Results</div>,
ResultsWidget: () => <div>Results Widget</div>,
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> })); vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> })); vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> })); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
@@ -129,22 +122,3 @@ describe('/admin route authorization', () => {
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument(); expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
}); });
}); });
describe('exam-marker routes', () => {
beforeEach(() => {
mockUseAuth.mockReset();
});
it('renders the mark scheme editor route for authenticated users', () => {
mockUseAuth.mockReturnValue(authState({
user: { id: 'teacher-1', email: '[email protected]', user_type: 'email_teacher' },
user_role: 'email_teacher',
accessToken: 'token',
}));
renderAt('/exam-marker/template-123/marks');
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
});
});
+1 -4
View File
@@ -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, ExamTemplateSetupPage } 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,9 +184,6 @@ const AppRoutes: React.FC = () => {
<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/: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/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
<Route path="/exam-marker/:batchId/results" element={<ErrorBoundary><ExamResultsPage /></ErrorBoundary>} />
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} /> <Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
<Route path="/morphic" element={<MorphicPage />} /> <Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} /> <Route path="/tldraw-dev" element={<TLDrawDevPage />} />
+35 -211
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
@@ -22,9 +22,6 @@ import {
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import ArchiveIcon from '@mui/icons-material/Archive'; import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment'; 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 { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository'; import { examRepository } from '../../services/exam/examRepository';
@@ -37,51 +34,6 @@ const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> =
archived: 'default', archived: 'default',
}; };
const VERSION_SEPARATOR = ' · ';
const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/;
type DialogMode = 'create' | 'edit' | 'duplicate';
type TemplateDialogState = {
mode: DialogMode;
template?: ExamTemplate;
} | null;
function splitTemplateTitle(title: string): { name: string; version: string } {
const parts = title.split(VERSION_SEPARATOR);
const possibleVersion = parts[parts.length - 1]?.trim() ?? '';
if (parts.length > 1 && VERSION_RE.test(possibleVersion)) {
return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion };
}
return { name: title, version: 'v1' };
}
function composeTemplateTitle(name: string, version: string): string {
const cleanName = name.trim();
const cleanVersion = version.trim();
return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName;
}
function nextVersionLabel(version: string): string {
const match = version.trim().match(VERSION_RE);
if (!match) return 'v2';
const segments = match[1].split('.');
const last = Number(segments[segments.length - 1]);
segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2';
return `v${segments.join('.')}`;
}
function paperKey(t: ExamTemplate): string {
return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper';
}
function paperLabel(t: ExamTemplate): string {
if (t.exam_code) return t.exam_code;
if (t.subject) return t.subject;
if (t.source_file_id) return 'Uploaded paper';
return 'Custom paper';
}
const ExamDashboardPage: React.FC = () => { const ExamDashboardPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { bootstrapData } = useAuth(); const { bootstrapData } = useAuth();
@@ -91,9 +43,8 @@ const ExamDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dialog, setDialog] = useState<TemplateDialogState>(null); const [createOpen, setCreateOpen] = useState(false);
const [templateName, setTemplateName] = useState(''); const [title, setTitle] = useState('');
const [version, setVersion] = useState('v1');
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -115,99 +66,33 @@ const ExamDashboardPage: React.FC = () => {
void load(); void load();
}, [load, instituteId]); }, [load, instituteId]);
const groupedTemplates = useMemo(() => { const handleCreate = async () => {
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>(); if (!title.trim()) return;
templates.forEach((template) => {
const key = paperKey(template);
const existing = groups.get(key);
if (existing) {
existing.templates.push(template);
} else {
groups.set(key, { label: paperLabel(template), templates: [template] });
}
});
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
}, [templates]);
const openCreate = () => {
setTemplateName('New template');
setVersion('v1');
setSubject('');
setDialog({ mode: 'create' });
};
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
setTemplateName(parsed.name);
setVersion(parsed.version);
setSubject(template.subject ?? '');
setDialog({ mode: 'edit', template });
};
const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
setTemplateName(parsed.name);
setVersion(nextVersionLabel(parsed.version));
setSubject(template.subject ?? '');
setDialog({ mode: 'duplicate', template });
};
const closeDialog = () => {
if (!saving) setDialog(null);
};
const handleSaveDialog = async () => {
if (!dialog || !templateName.trim()) return;
const title = composeTemplateTitle(templateName, version);
setSaving(true); setSaving(true);
try { try {
if (dialog.mode === 'create') {
const created = await examRepository.createTemplate({ const created = await examRepository.createTemplate({
title, title: title.trim(),
subject: subject.trim() || undefined, subject: subject.trim() || undefined,
institute_id: instituteId, institute_id: instituteId,
}); });
setDialog(null); setCreateOpen(false);
setTitle('');
setSubject('');
navigate(`/exam-marker/${created.id}/setup`); navigate(`/exam-marker/${created.id}/setup`);
return;
}
if (!dialog.template) return;
if (dialog.mode === 'duplicate') {
const created = await examRepository.duplicateTemplate(dialog.template.id, title);
setTemplates((prev) => [created, ...prev]);
setDialog(null);
navigate(`/exam-marker/${created.id}/setup`);
return;
}
const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
title,
subject: subject.trim() || null,
});
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setDialog(null);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode }); logger.error('cc-exam-marker', 'Create template failed', { message: msg });
setError(msg); setError(msg);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => { const handleArchive = async (id: string, ev: React.MouseEvent) => {
ev.stopPropagation(); ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) {
return;
}
try { try {
await examRepository.archiveTemplate(template.id); await examRepository.archiveTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== template.id)); setTemplates((prev) => prev.filter((t) => t.id !== id));
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Archive failed', { message: msg }); logger.error('cc-exam-marker', 'Archive failed', { message: msg });
@@ -215,12 +100,6 @@ const ExamDashboardPage: React.FC = () => {
} }
}; };
const dialogTitle = dialog?.mode === 'edit'
? 'Rename template / edit version'
: dialog?.mode === 'duplicate'
? 'Duplicate as new version'
: 'New exam template';
return ( return (
<Container maxWidth="lg" sx={{ py: 6 }}> <Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={4}> <Stack spacing={4}>
@@ -229,11 +108,11 @@ const ExamDashboardPage: React.FC = () => {
<Typography variant="h3" component="h1" gutterBottom> <Typography variant="h3" component="h1" gutterBottom>
Exam Marker Exam Marker
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}> <Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need. Build a template for an exam paper, then run marking batches against your classes.
</Typography> </Typography>
</Box> </Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}> <Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New template New template
</Button> </Button>
</Box> </Box>
@@ -253,61 +132,29 @@ const ExamDashboardPage: React.FC = () => {
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} /> <AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography variant="h6" gutterBottom>No exam templates yet</Typography> <Typography variant="h6" gutterBottom>No exam templates yet</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Create your first named template to start mapping an exam paper. Create your first template to start mapping an exam paper.
</Typography> </Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}> <Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New template New template
</Button> </Button>
</Paper> </Paper>
) : ( ) : (
<Stack spacing={3}>
{groupedTemplates.map((group) => (
<Box key={group.key}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="h6">{group.label}</Typography>
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
</Stack>
<Grid container spacing={3}> <Grid container spacing={3}>
{group.templates.map((t) => { {templates.map((t) => (
const parsed = splitTemplateTitle(t.title);
return (
<Grid item xs={12} sm={6} md={4} key={t.id}> <Grid item xs={12} sm={6} md={4} key={t.id}>
<Paper <Paper
elevation={2} elevation={2}
sx={{ sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
p: 3, transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
height: '100%',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 1,
transition: 'box-shadow 120ms',
'&:hover': { boxShadow: 6 },
}}
onClick={() => navigate(`/exam-marker/${t.id}/setup`)} onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
> >
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Box sx={{ minWidth: 0 }}> <Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
</Box>
<Stack direction="row" spacing={0.5}>
<Tooltip title="Rename / edit version">
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Duplicate as new version">
<IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Archive"> <Tooltip title="Archive">
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template"> <IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
<ArchiveIcon fontSize="small" /> <ArchiveIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Stack>
</Box> </Box>
{t.subject && ( {t.subject && (
<Typography variant="body2" color="text.secondary">{t.subject}</Typography> <Typography variant="body2" color="text.secondary">{t.subject}</Typography>
@@ -318,63 +165,40 @@ const ExamDashboardPage: React.FC = () => {
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" /> <Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Updated {new Date(t.updated_at).toLocaleDateString()} {new Date(t.updated_at).toLocaleDateString()}
</Typography> </Typography>
</Box> </Box>
<Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
<Button
size="small"
variant="outlined"
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
startIcon={<GradingIcon fontSize="small" />}
>
Edit marks
</Button>
</Stack>
</Paper> </Paper>
</Grid> </Grid>
);
})}
</Grid>
</Box>
))} ))}
</Stack> </Grid>
)} )}
</Stack> </Stack>
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm"> <Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
<DialogTitle>{dialogTitle}</DialogTitle> <DialogTitle>New exam template</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}> <Stack spacing={2} sx={{ mt: 1 }}>
<TextField <TextField
label="Template name" label="Title"
value={templateName} value={title}
onChange={(e) => setTemplateName(e.target.value)} onChange={(e) => setTitle(e.target.value)}
fullWidth fullWidth
autoFocus autoFocus
required required
helperText="User-facing name. Several templates can share the same paper."
/> />
<TextField <TextField
label="Version" label="Subject"
value={version}
onChange={(e) => setVersion(e.target.value)}
fullWidth
helperText="Stored in the template title until the API grows a dedicated version column."
/>
<TextField
label="Paper / subject label"
value={subject} value={subject}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => setSubject(e.target.value)}
fullWidth fullWidth
disabled={dialog?.mode === 'duplicate'}
/> />
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={closeDialog} disabled={saving}>Cancel</Button> <Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}> <Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'} {saving ? 'Creating…' : 'Create'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
-233
View File
@@ -1,233 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { v5 as uuidv5 } from 'uuid';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Container,
Divider,
List,
ListItemButton,
ListItemText,
Stack,
TextField,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import TableChartIcon from '@mui/icons-material/TableChart';
import { examRepository } from '../../services/exam/examRepository';
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0';
function stableMarkId(batchId: string, submissionId: string, questionId: string) {
return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE);
}
const ExamMarkingPage: React.FC = () => {
const { batchId } = useParams<{ batchId: string }>();
const navigate = useNavigate();
const [queue, setQueue] = useState<BatchQueueResponse | null>(null);
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [marks, setMarks] = useState<Record<string, string>>({});
const [comments, setComments] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!batchId) return;
setLoading(true);
setError(null);
try {
const nextQueue = await examRepository.getBatchQueue(batchId);
setQueue(nextQueue);
setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id));
setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [batchId]);
useEffect(() => {
void load();
}, [load]);
const markableQuestions = useMemo(
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
[template],
);
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
const saveSelected = async () => {
if (!batchId || !selected) return;
setSaving(true);
setError(null);
setMessage(null);
try {
const writes = markableQuestions
.map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] }))
.filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim());
for (const { q, raw, comment } of writes) {
const awarded = raw === undefined || raw === '' ? 0 : Number(raw);
if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) {
throw new Error(`Invalid mark for ${q.label}`);
}
await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), {
submission_id: selected.id,
question_id: q.id,
awarded_marks: awarded,
comment: comment?.trim() || undefined,
confirmed: true,
});
}
setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`);
setMarks({});
setComments({});
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const chooseStudent = (submission: StudentSubmission) => {
setSelectedId(submission.id);
setMarks({});
setComments({});
setMessage(null);
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
}
if (error && !queue) {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Alert severity="error">{error}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
Back to Exam Marker
</Button>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 3 }}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Typography variant="h4" component="h1" fontWeight={700}>{queue?.batch.title || 'Mark exam'}</Typography>
<Typography variant="body2" color="text.secondary">
{template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
</Typography>
</Box>
<Button variant="outlined" startIcon={<TableChartIcon />} onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
Results
</Button>
</Box>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{message && <Alert severity="success" onClose={() => setMessage(null)}>{message}</Alert>}
{markableQuestions.length === 0 && (
<Alert severity="warning">
This template has no markable parts yet. Add parts in template setup before entering marks.
</Alert>
)}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
<CardContent>
<Typography variant="h6" gutterBottom>Marking queue</Typography>
<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.absent ?? 0} absent`} color="warning" variant="outlined" />
<Chip size="small" label={`${queue?.progress.complete ?? 0} complete`} color="success" variant="outlined" />
</Stack>
<List dense disablePadding>
{(queue?.submissions ?? []).map((submission) => (
<ListItemButton
key={submission.id}
selected={submission.id === selectedId}
onClick={() => chooseStudent(submission)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemText
primary={submission.student_name || submission.student_id || 'Unknown student'}
secondary={`${submission.status} · ${submission.mark_entry_count ?? 0} marks`}
/>
<Chip size="small" label={submission.status} color={submission.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
</ListItemButton>
))}
</List>
</CardContent>
</Card>
<Card variant="outlined" sx={{ flex: 1 }}>
<CardContent>
{selected ? (
<Stack spacing={2}>
<Box>
<Typography variant="h6">{selected.student_name || selected.student_id || 'Unknown student'}</Typography>
<Typography variant="body2" color="text.secondary">Status: {selected.status}</Typography>
</Box>
<Divider />
{markableQuestions.map((q: ExamQuestion) => (
<Stack key={q.id} direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ xs: 'stretch', sm: 'center' }}>
<Box sx={{ minWidth: 150 }}>
<Typography variant="body2" fontWeight={700}>{q.label}</Typography>
<Typography variant="caption" color="text.secondary">/ {q.max_marks} marks</Typography>
</Box>
<TextField
label="Mark"
type="number"
size="small"
inputProps={{ min: 0, max: q.max_marks, step: 0.5 }}
value={marks[q.id] ?? ''}
onChange={(e) => setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
sx={{ width: { xs: '100%', sm: 120 } }}
/>
<TextField
label="Comment"
size="small"
value={comments[q.id] ?? ''}
onChange={(e) => setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
sx={{ flex: 1 }}
/>
</Stack>
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={() => navigate(`/exam-marker/${batchId}/results`)}>Skip to results</Button>
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
{saving ? 'Saving…' : 'Save marks'}
</Button>
</Box>
</Stack>
) : (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No submissions in this batch.</Typography>
)}
</CardContent>
</Card>
</Stack>
</Stack>
</Container>
);
};
export default ExamMarkingPage;
-177
View File
@@ -1,177 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DownloadIcon from '@mui/icons-material/Download';
import EditIcon from '@mui/icons-material/Edit';
import { examRepository } from '../../services/exam/examRepository';
import type { BatchResultsResponse } from '../../types/exam.types';
function formatMark(value: number | null | undefined) {
return value === null || value === undefined ? '' : String(value);
}
const ExamResultsPage: React.FC = () => {
const { batchId } = useParams<{ batchId: string }>();
const navigate = useNavigate();
const [data, setData] = useState<BatchResultsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!batchId) return;
setLoading(true);
setError(null);
try {
setData(await examRepository.getBatchResults(batchId));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [batchId]);
useEffect(() => {
void load();
}, [load]);
const summary = useMemo(() => {
const rows = data?.results ?? [];
const presentTotals = rows
.map((r) => r.total)
.filter((v): v is number => typeof v === 'number');
const average = presentTotals.length
? presentTotals.reduce((sum, v) => sum + v, 0) / presentTotals.length
: null;
return {
total: rows.length,
absent: rows.filter((r) => r.status === 'absent' && r.total === null).length,
marked: rows.filter((r) => r.total !== null).length,
average,
};
}, [data]);
const downloadCsv = async () => {
if (!batchId) return;
setDownloading(true);
setError(null);
try {
const csv = await examRepository.getBatchCsv(batchId);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `batch-${batchId}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDownloading(false);
}
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
}
if (error || !data) {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Alert severity="error">{error || 'Results not found'}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
Back to Exam Marker
</Button>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Typography variant="h4" component="h1" fontWeight={700}>
{data.batch.title || 'Exam results'}
</Typography>
<Typography variant="body2" color="text.secondary">
Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button variant="outlined" startIcon={<EditIcon />} onClick={() => navigate(`/exam-marker/${data.batch.id}/mark`)}>
Mark
</Button>
<Button variant="contained" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={downloading}>
{downloading ? 'Preparing…' : 'Download CSV'}
</Button>
</Stack>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Chip label={`${summary.total} students`} />
<Chip label={`${summary.marked} with marks`} color="success" variant="outlined" />
<Chip label={`${summary.absent} absent/no scan`} color="warning" variant="outlined" />
<Chip label={`Class average ${summary.average === null ? '—' : summary.average.toFixed(1)}`} color="primary" />
</Stack>
<TableContainer component={Paper} variant="outlined">
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Student</TableCell>
<TableCell>Status</TableCell>
{data.questions.map((q) => (
<TableCell key={q.id} align="right">{q.label} / {q.max_marks}</TableCell>
))}
<TableCell align="right">Total</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.results.map((row) => (
<TableRow key={row.submission_id} sx={row.status === 'absent' && row.total === null ? { opacity: 0.72 } : undefined}>
<TableCell>
<Typography variant="body2" fontWeight={600}>{row.student_name || row.student_id || 'Unknown student'}</Typography>
{row.student_id && <Typography variant="caption" color="text.secondary">{row.student_id}</Typography>}
</TableCell>
<TableCell>
<Chip size="small" label={row.status || 'unknown'} color={row.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
</TableCell>
{data.questions.map((q) => (
<TableCell key={q.id} align="right">{formatMark(row.marks[q.id])}</TableCell>
))}
<TableCell align="right"><strong>{formatMark(row.total)}</strong></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Stack>
</Container>
);
};
export default ExamResultsPage;
-501
View File
@@ -1,501 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Autocomplete,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Container,
Divider,
FormControl,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import SyncIcon from '@mui/icons-material/Sync';
import { logger } from '../../debugConfig';
import { examRepository } from '../../services/exam/examRepository';
import type {
ExamQuestion,
ExamTemplateDetail,
MarkScheme,
MarkSchemeType,
SpecPoint,
} from '../../types/exam.types';
type Drafts = Record<string, PartDraft>;
interface PartDraft {
max_marks: string;
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
spec_ref: string;
schemeType: MarkSchemeType;
body: string;
notes: string;
}
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['written', 'short', 'mcq', 'diagram'];
const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [
{ value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' },
{ value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' },
{ value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' },
{ value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' },
{ value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' },
];
function deriveSpecCode(template: ExamTemplateDetail | null): string {
const examCode = template?.exam_code ?? '';
const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i);
return match?.[1] ?? '';
}
function schemeToDraft(scheme: MarkScheme | null | undefined): Pick<PartDraft, 'schemeType' | 'body' | 'notes'> {
const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points';
const notes = typeof scheme?.notes === 'string' ? scheme.notes : '';
if (type === 'levels' && Array.isArray(scheme?.levels)) {
return {
schemeType: 'levels',
body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'),
notes,
};
}
if (type === 'parts' && Array.isArray(scheme?.parts)) {
return {
schemeType: 'parts',
body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'),
notes,
};
}
if (type === 'checklist' && Array.isArray(scheme?.checklist)) {
return {
schemeType: 'checklist',
body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'),
notes,
};
}
if (type === 'free') {
return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes };
}
if (Array.isArray(scheme?.points)) {
return {
schemeType: 'points',
body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'),
notes,
};
}
return { schemeType: type, body: '', notes };
}
function parseNumber(value: string, fallback = 0): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function splitLine(line: string): string[] {
return line.split('|').map((part) => part.trim());
}
function draftToScheme(draft: PartDraft): MarkScheme {
const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean);
if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined };
if (draft.schemeType === 'levels') {
return {
type: 'levels',
levels: lines.map((line, idx) => {
const [level, range, descriptor] = splitLine(line);
const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim());
return {
level: level || `L${idx + 1}`,
min: parseNumber(minRaw, 0),
max: parseNumber(maxRaw, parseNumber(minRaw, 0)),
descriptor: descriptor || line,
};
}),
notes: draft.notes || undefined,
};
}
if (draft.schemeType === 'parts') {
return {
type: 'parts',
parts: lines.map((line, idx) => {
const [label, marks, guidance] = splitLine(line);
return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line };
}),
notes: draft.notes || undefined,
};
}
if (draft.schemeType === 'checklist') {
return {
type: 'checklist',
checklist: lines.map((line) => {
const [marks, text] = splitLine(line);
return { marks: parseNumber(marks, 1), text: text || line };
}),
notes: draft.notes || undefined,
};
}
return {
type: 'points',
points: lines.map((line) => {
const [mark, text] = splitLine(line);
return { mark: parseNumber(mark, 1), text: text || line };
}),
notes: draft.notes || undefined,
};
}
function makeDraft(question: ExamQuestion): PartDraft {
const scheme = schemeToDraft(question.mark_scheme);
return {
max_marks: String(question.max_marks ?? 0),
answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written',
spec_ref: question.spec_ref ?? '',
schemeType: scheme.schemeType,
body: scheme.body,
notes: scheme.notes,
};
}
const MarkSchemePage: React.FC = () => {
const navigate = useNavigate();
const { templateId } = useParams<{ templateId: string }>();
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Drafts>({});
const [specCode, setSpecCode] = useState('');
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [specEndpointMissing, setSpecEndpointMissing] = useState(false);
const load = useCallback(async () => {
if (!templateId) return;
setLoading(true);
setError(null);
try {
const detail = await examRepository.getTemplate(templateId);
const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
setTemplate(detail);
setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null);
setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)])));
setSpecCode((prev) => prev || deriveSpecCode(detail));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId });
setError(msg);
} finally {
setLoading(false);
}
}, [templateId]);
useEffect(() => {
void load();
}, [load]);
const loadSpecPoints = useCallback(async () => {
if (!specCode.trim()) return;
setSpecEndpointMissing(false);
try {
setSpecPoints(await examRepository.listSpecPoints(specCode.trim()));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode });
setSpecEndpointMissing(true);
setSpecPoints([]);
}
}, [specCode]);
useEffect(() => {
void loadSpecPoints();
}, [loadSpecPoints]);
const parts = useMemo(
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
[template],
);
const selected = parts.find((p) => p.id === selectedId) ?? null;
const draft = selected ? drafts[selected.id] : null;
const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null;
const updateDraft = (questionId: string, patch: Partial<PartDraft>) => {
setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } }));
};
const savePart = async (question: ExamQuestion) => {
const partDraft = drafts[question.id];
if (!partDraft) return;
setSavingId(question.id);
setError(null);
setNotice(null);
try {
await examRepository.patchQuestion(question.id, {
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
answer_type: partDraft.answer_type,
mark_scheme: draftToScheme(partDraft),
spec_ref: partDraft.spec_ref.trim() || null,
});
setTemplate((prev) => {
if (!prev) return prev;
return {
...prev,
questions: prev.questions.map((q) => q.id === question.id ? {
...q,
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
answer_type: partDraft.answer_type,
mark_scheme: draftToScheme(partDraft),
spec_ref: partDraft.spec_ref.trim() || null,
} : q),
};
});
setNotice(`Saved mark scheme for Part ${question.label}.`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id });
setError(msg);
} finally {
setSavingId(null);
}
};
const syncGraph = async () => {
if (!templateId) return;
setSyncing(true);
setError(null);
setNotice(null);
try {
const res = await examRepository.syncTemplateToGraph(templateId);
setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId });
setError(msg);
} finally {
setSyncing(false);
}
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Back to templates
</Button>
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
<Typography variant="body1" color="text.secondary">
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
{syncing ? 'Syncing…' : 'Sync to graph'}
</Button>
</Stack>
</Box>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
{specEndpointMissing && (
<Alert severity="warning">
SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync.
</Alert>
)}
<Paper variant="outlined" sx={{ p: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={4}>
<TextField
label="Specification code"
value={specCode}
onChange={(e) => setSpecCode(e.target.value)}
fullWidth
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
/>
</Grid>
<Grid item xs={12} md={8}>
<Typography variant="body2" color="text.secondary">
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]-&gt;(:SpecPoint).
</Typography>
</Grid>
</Grid>
</Paper>
{parts.length === 0 ? (
<Alert severity="info">
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
</Alert>
) : (
<Grid container spacing={3}>
<Grid item xs={12} md={3}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>Parts</Typography>
<Tabs
orientation="vertical"
value={selectedId ?? false}
onChange={(_, value) => setSelectedId(value)}
variant="scrollable"
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
>
{parts.map((part) => (
<Tab
key={part.id}
value={part.id}
label={
<Box sx={{ textAlign: 'left', width: '100%' }}>
<Typography variant="body2">Part {part.label}</Typography>
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
</Stack>
</Box>
}
/>
))}
</Tabs>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={9}>
{selected && draft && (
<Card variant="outlined">
<CardContent>
<Stack spacing={3}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Typography variant="h5">Part {selected.label}</Typography>
<Typography variant="body2" color="text.secondary">
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
</Typography>
</Box>
<Button
variant="contained"
startIcon={<SaveIcon />}
disabled={savingId === selected.id}
onClick={() => savePart(selected)}
>
{savingId === selected.id ? 'Saving…' : 'Save part'}
</Button>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField
label="Max marks"
value={draft.max_marks}
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
type="number"
inputProps={{ min: 0, step: 0.5 }}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel id="answer-type-label">Answer type</InputLabel>
<Select
labelId="answer-type-label"
label="Answer type"
value={draft.answer_type}
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
>
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
<Select
labelId="scheme-type-label"
label="Scheme form"
value={draft.schemeType}
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
>
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
</Select>
</FormControl>
</Grid>
</Grid>
<Autocomplete
options={specPoints}
value={selectedSpecPoint}
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
getOptionLabel={(option) => `${option.ref}${option.description}`}
isOptionEqualToValue={(option, value) => option.ref === value.ref}
renderInput={(params) => (
<TextField
{...params}
label="SpecPoint picker"
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
/>
)}
/>
<TextField
label="spec_ref"
value={draft.spec_ref}
onChange={(e) => updateDraft(selected.id, { spec_ref: e.target.value })}
helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint."
fullWidth
/>
<Divider />
<TextField
label={`${SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`}
value={draft.body}
onChange={(e) => updateDraft(selected.id, { body: e.target.value })}
helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper}
multiline
minRows={10}
fullWidth
/>
<TextField
label="Internal marking notes"
value={draft.notes}
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
multiline
minRows={3}
fullWidth
/>
</Stack>
</CardContent>
</Card>
)}
</Grid>
</Grid>
)}
</Stack>
</Container>
);
};
export default MarkSchemePage;
-169
View File
@@ -1,169 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
MenuItem,
Stack,
TextField,
Typography,
} from '@mui/material';
import AssessmentIcon from '@mui/icons-material/Assessment';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import TableChartIcon from '@mui/icons-material/TableChart';
import { examRepository } from '../../services/exam/examRepository';
import type { BatchResultsResponse, ExamTemplate, MarkingBatch } from '../../types/exam.types';
interface ResultsWidgetProps {
classId: string;
className?: string;
}
function averageFromResults(results: BatchResultsResponse | null) {
const totals = (results?.results ?? [])
.map((row) => row.total)
.filter((value): value is number => typeof value === 'number');
if (!totals.length) return null;
return totals.reduce((sum, value) => sum + value, 0) / totals.length;
}
const ResultsWidget: React.FC<ResultsWidgetProps> = ({ classId, className }) => {
const navigate = useNavigate();
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
const [batches, setBatches] = useState<MarkingBatch[]>([]);
const [latestResults, setLatestResults] = useState<BatchResultsResponse | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextTemplates, nextBatches] = await Promise.all([
examRepository.listTemplates(),
examRepository.listBatches(),
]);
const classBatches = nextBatches
.filter((batch) => batch.class_id === classId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setTemplates(nextTemplates);
setSelectedTemplateId((current) => current || nextTemplates[0]?.id || '');
setBatches(classBatches);
setLatestResults(classBatches[0] ? await examRepository.getBatchResults(classBatches[0].id) : null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [classId]);
useEffect(() => {
void load();
}, [load]);
const latestBatch = batches[0] ?? null;
const average = useMemo(() => averageFromResults(latestResults), [latestResults]);
const absent = latestResults?.results.filter((row) => row.status === 'absent' && row.total === null).length ?? 0;
const createBatch = async () => {
if (!selectedTemplateId) return;
setCreating(true);
setError(null);
try {
const template = templates.find((item) => item.id === selectedTemplateId);
const created = await examRepository.createBatch({
template_id: selectedTemplateId,
class_id: classId,
title: `${className || 'Class'} · ${template?.title || 'Exam'}`,
});
navigate(`/exam-marker/${created.id}/mark`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setCreating(false);
}
};
return (
<Card variant="outlined" sx={{ mb: 2 }}>
<CardContent>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssessmentIcon color="primary" />
<Box>
<Typography variant="h6">Assessment results</Typography>
<Typography variant="caption" color="text.secondary">Last exam summary for this class</Typography>
</Box>
</Box>
{loading && <CircularProgress size={22} />}
</Box>
{error && <Alert severity="warning" onClose={() => setError(null)}>{error}</Alert>}
{latestBatch ? (
<Stack spacing={1.5}>
<Box>
<Typography variant="body2" fontWeight={700}>{latestBatch.title || 'Exam batch'}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(latestBatch.created_at).toLocaleDateString('en-GB')}
</Typography>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Chip size="small" color="primary" label={`Average ${average === null ? '—' : average.toFixed(1)}`} />
<Chip size="small" variant="outlined" label={`${latestResults?.results.length ?? 0} students`} />
<Chip size="small" color="warning" variant="outlined" label={`${absent} absent`} />
</Stack>
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" startIcon={<TableChartIcon />} onClick={() => navigate(`/exam-marker/${latestBatch.id}/results`)}>
View results
</Button>
<Button size="small" variant="outlined" onClick={() => navigate(`/exam-marker/${latestBatch.id}/mark`)}>
Continue marking
</Button>
</Stack>
</Stack>
) : !loading ? (
<Typography variant="body2" color="text.secondary">
No exam batches have been created for this class yet.
</Typography>
) : null}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'center' }}>
<TextField
select
size="small"
label="Template"
value={selectedTemplateId}
onChange={(e) => setSelectedTemplateId(e.target.value)}
disabled={!templates.length || creating}
sx={{ minWidth: 260 }}
>
{templates.map((template) => (
<MenuItem key={template.id} value={template.id}>{template.title}</MenuItem>
))}
</TextField>
<Button
variant="outlined"
startIcon={<PlayArrowIcon />}
onClick={createBatch}
disabled={!selectedTemplateId || creating}
>
{creating ? 'Creating…' : 'Create marking batch'}
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
};
export default ResultsWidget;
-4
View File
@@ -1,6 +1,2 @@
export { default as ExamDashboardPage } from './ExamDashboardPage'; export { default as ExamDashboardPage } from './ExamDashboardPage';
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage'; export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
export { default as MarkSchemePage } from './MarkSchemePage';
export { default as ExamMarkingPage } from './ExamMarkingPage';
export { default as ExamResultsPage } from './ExamResultsPage';
export { default as ResultsWidget } from './ResultsWidget';
+78 -132
View File
@@ -5,42 +5,31 @@ import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, S
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 MouseIcon from '@mui/icons-material/Mouse' import MouseIcon from '@mui/icons-material/Mouse'
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
import '@tldraw/tldraw/tldraw.css' import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw' import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw'
import axios from 'axios' import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary' import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig' import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository' import { examRepository } from '../../../services/exam/examRepository'
import type { ExamTemplateDetail } from '../../../types/exam.types' import type { ExamTemplateDetail } from '../../../types/exam.types'
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' import { ExamCanvasShapeModel, PAGE_WIDTH, type PdfPageLayout, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes' import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, PDF_PAGE_SHAPE_TYPE, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes'
import { loadPdfPageImages, PdfPageImage } from './pdfLoader' import { renderPdfBackdrop, replacePdfPageShapes } from './pdfLoader'
const TOOLS = [ const TOOLS = [
{ id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const }, { id: 'select', label: 'Select', tip: 'Move, resize, or delete user regions. The PDF backdrop is locked.', color: 'default' as const },
{ id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const }, { id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const },
{ id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const }, { id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const },
{ id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const }, { id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const },
{ id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const }, { id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const },
{ id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const }, { id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const },
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const }, { id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const },
{ id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const }, { id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const },
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const }, { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const },
] ]
const PAGE_START_X = 260
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
let y = 0
return pages.map((page) => {
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
y += page.height
return geometry
})
}
function apiMessage(err: unknown): { message: string; conflict: boolean } { function apiMessage(err: unknown): { message: string; conflict: boolean } {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const detail = (err.response?.data as { detail?: string } | undefined)?.detail const detail = (err.response?.data as { detail?: string } | undefined)?.detail
@@ -54,27 +43,12 @@ function stripShapePrefix(id: string) {
return id.startsWith('shape:') ? id.slice('shape:'.length) : id return id.startsWith('shape:') ? id.slice('shape:'.length) : id
} }
function domainIdForShape(shape: ExamCanvasTLShape): string {
const fromProps = shape.props.domainId
if (isUuid(fromProps)) return fromProps
const fromShapeId = stripShapePrefix(shape.id)
return isUuid(fromShapeId) ? fromShapeId : newDomainId()
}
function ensureDomainIds(editor: Editor) {
const updates = editor.getCurrentPageShapes()
.filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type))
.filter((shape) => !isUuid(shape.props.domainId))
.map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } }))
if (updates.length) editor.updateShapes(updates)
}
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null { function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
const kind = shapeTypeToKind(shape.type) const kind = shapeTypeToKind(shape.type)
if (!kind) return null if (!kind) return null
const s = shape as ExamCanvasTLShape const s = shape as ExamCanvasTLShape
return { return {
id: domainIdForShape(s), id: s.props.domainId || stripShapePrefix(s.id),
kind, kind,
x: Number(s.x ?? 0), x: Number(s.x ?? 0),
y: Number(s.y ?? 0), y: Number(s.y ?? 0),
@@ -88,52 +62,32 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
} }
} }
function bringDomainShapesToFront(editor: Editor) {
const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id)
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
}
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) { function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
if (!models.length) return
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id) const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing) if (existing.length) editor.deleteShapes(existing)
if (!models.length) return
editor.createShapes(models.map((m) => ({ editor.createShapes(models.map((m) => ({
id: createShapeId(m.id), id: createShapeId(m.id),
type: SHAPE_TYPES[m.kind], type: SHAPE_TYPES[m.kind],
x: m.x, x: m.x,
y: m.y, y: m.y,
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id }, props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, domainId: m.id, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId },
}))) })))
} }
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) { function newDomainId() {
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id) if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
if (existing.length) editor.deleteShapes(existing) return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}`
if (!pages.length) return
const geometries = pageGeometryFromImages(pages)
editor.createShapes(geometries.map((geometry) => {
const page = pages[geometry.pageNumber - 1]
return {
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
type: PDF_PAGE_SHAPE_TYPE,
x: geometry.x,
y: geometry.y,
isLocked: true,
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
} as any
}))
// z-order is enforced by the caller via bringDomainShapesToFront
} }
function seedGuide(editor: Editor) { function seedGuide(editor: Editor) {
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
if (current.length) return if (current.length) return
editor.createShapes([ editor.createShapes([
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 start' } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 end' } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', domainId: newDomainId(), label: 'Q1(a)', maxMarks: 3 } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', domainId: newDomainId(), label: 'Response', responseForm: 'lines' } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } },
]) ])
} }
@@ -142,16 +96,39 @@ const ExamTemplateSetupInner: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const theme = useTheme() const theme = useTheme()
const editorRef = useRef<Editor | null>(null) const editorRef = useRef<Editor | null>(null)
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([]) const pageLayoutsRef = useRef<PdfPageLayout[]>([])
const hydratingRef = useRef(false)
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null) const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [pdfLoading, setPdfLoading] = useState(false)
const [pdfStatus, setPdfStatus] = useState<string>('PDF not loaded')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false) const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState<string | null>(null) const [conflict, setConflict] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState('select') const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const [pdfError, setPdfError] = useState<string | null>(null) const loadPdf = useCallback(async (id: string, editor: Editor) => {
setPdfLoading(true)
setPdfStatus('Loading PDF…')
try {
const result = await renderPdfBackdrop(id)
hydratingRef.current = true
replacePdfPageShapes(editor, result.pages)
pageLayoutsRef.current = result.pageLayouts
setPdfStatus(`${result.pages.length} PDF page${result.pages.length === 1 ? '' : 's'} loaded`)
editor.zoomToFit()
} catch (e) {
pageLayoutsRef.current = []
const msg = apiMessage(e).message
setPdfStatus(`PDF unavailable: ${msg}`)
logger.warn('cc-exam-marker', 'Template setup PDF backdrop failed', { templateId: id, message: msg })
} finally {
hydratingRef.current = false
setPdfLoading(false)
setDirty(false)
}
}, [])
const load = useCallback(async () => { const load = useCallback(async () => {
if (!templateId) return if (!templateId) return
@@ -159,40 +136,12 @@ const ExamTemplateSetupInner: React.FC = () => {
try { try {
const detail = await examRepository.getTemplate(templateId) const detail = await examRepository.getTemplate(templateId)
setTemplate(detail) setTemplate(detail)
let pages: PdfPageImage[] = []
setPdfStatus('loading')
setPdfError(null)
try {
const bytes = await examRepository.getTemplateSourcePdf(templateId)
pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
const newPage = partialPages[partialPages.length - 1]
const allGeometries = pageGeometryFromImages(partialPages)
pageGeometriesRef.current = allGeometries
const ed = editorRef.current
if (ed) {
const geometry = allGeometries[partialPages.length - 1]
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
bringDomainShapesToFront(ed)
}
}
setPdfStatus('ready')
})
setPdfStatus(pages.length ? 'ready' : 'missing')
} catch (pdfErr) {
const pdfMsg = apiMessage(pdfErr).message
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
setPdfError(pdfMsg)
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
}
const geometries = pageGeometryFromImages(pages)
pageGeometriesRef.current = geometries
const editor = editorRef.current const editor = editorRef.current
if (editor) { if (editor) {
syncPdfPages(editor, pages) hydratingRef.current = true
loadShapes(editor, shapesFromTemplate(detail, geometries)) loadShapes(editor, shapesFromTemplate(detail))
bringDomainShapesToFront(editor) hydratingRef.current = false
await loadPdf(templateId, editor)
} }
setDirty(false) setDirty(false)
} catch (e) { } catch (e) {
@@ -202,7 +151,7 @@ const ExamTemplateSetupInner: React.FC = () => {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [templateId]) }, [loadPdf, templateId])
useEffect(() => { void load() }, [load]) useEffect(() => { void load() }, [load])
@@ -211,12 +160,13 @@ const ExamTemplateSetupInner: React.FC = () => {
if (!editor || !templateId || !template) return if (!editor || !templateId || !template) return
setSaving(true); setError(null); setConflict(null) setSaving(true); setError(null); setConflict(null)
try { try {
ensureDomainIds(editor) const shapes = editor.getCurrentPageShapes().filter((s) => s.type !== PDF_PAGE_SHAPE_TYPE).map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[] const payload = serializeCanvasShapes(template, shapes, pageLayoutsRef.current)
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
const saved = await examRepository.replaceTemplate(templateId, payload) const saved = await examRepository.replaceTemplate(templateId, payload)
setTemplate(saved) setTemplate(saved)
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current)) hydratingRef.current = true
loadShapes(editor, shapesFromTemplate(saved))
hydratingRef.current = false
setDirty(false) setDirty(false)
} catch (e) { } catch (e) {
const msg = apiMessage(e) const msg = apiMessage(e)
@@ -233,7 +183,7 @@ const ExamTemplateSetupInner: React.FC = () => {
size="small" size="small"
variant={activeTool === tool.id ? 'contained' : 'outlined'} variant={activeTool === tool.id ? 'contained' : 'outlined'}
color={tool.color} color={tool.color}
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>} startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : undefined}
onClick={() => { onClick={() => {
const editor = editorRef.current const editor = editorRef.current
if (!editor) return if (!editor) return
@@ -252,16 +202,24 @@ const ExamTemplateSetupInner: React.FC = () => {
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas"> <Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
<Tldraw <Tldraw
shapeUtils={examCanvasShapeUtils as any} shapeUtils={examCanvasShapeUtils as any}
bindingUtils={defaultBindingUtils}
tools={examCanvasTools as any} tools={examCanvasTools as any}
hideUi hideUi
inferDarkMode={theme.palette.mode === 'dark'} inferDarkMode={theme.palette.mode === 'dark'}
autoFocus autoFocus
onMount={(editor) => { onMount={(editor) => {
editorRef.current = editor editorRef.current = editor
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
editor.store.listen(() => setDirty(true), { scope: 'document' }) editor.store.listen((entry) => {
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor) const changedPdfOnly = Object.values(entry.changes.added).every((record: any) => record?.type === PDF_PAGE_SHAPE_TYPE)
bringDomainShapesToFront(editor) if (!hydratingRef.current && !changedPdfOnly) setDirty(true)
}, { scope: 'document' })
if (template) {
hydratingRef.current = true
loadShapes(editor, shapesFromTemplate(template))
hydratingRef.current = false
if (templateId) void loadPdf(templateId, editor)
} else seedGuide(editor)
}} }}
/> />
</Box> </Box>
@@ -271,8 +229,9 @@ const ExamTemplateSetupInner: React.FC = () => {
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography> <Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
<Typography variant="caption" color="text.secondary">Exam Marker Setup · coloured tools map to persisted regions; boundary start/end pairs can span pages.</Typography> <Typography variant="caption" color="text.secondary">Exam Marker Setup · real PDF backdrop is locked; drawn regions persist with PDF-derived page numbers.</Typography>
</Box> </Box>
<Chip size="small" icon={<PictureAsPdfIcon />} color={pdfStatus.includes('loaded') ? 'info' : pdfStatus.includes('unavailable') ? 'warning' : 'default'} label={pdfLoading ? 'Loading PDF…' : pdfStatus} />
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} /> <Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button> <Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
</Paper> </Paper>
@@ -281,23 +240,10 @@ const ExamTemplateSetupInner: React.FC = () => {
<Stack spacing={1}>{toolButtons}</Stack> <Stack spacing={1}>{toolButtons}</Stack>
</Paper> </Paper>
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 460, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}> <Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography> <Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary">
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment. 1) Use the visible PDF pages as the paper coordinate system. 2) Draw top/bottom Boundary lines, Part boxes, then regions over the real paper. 3) Save derives page numbers from the PDF page under each shape and reloads them at the same canvas location.
</Typography>
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
const p = canvasShapePalette[kind]
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
})}
</Stack>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw Q start on page N, then Q end on a later page; save pairs boundaries by reading order into one question span.</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}>Open design choices resolved for v1: labels use Q start/end; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit.</Typography>
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'}
</Typography> </Typography>
</Paper> </Paper>
+49 -73
View File
@@ -1,4 +1,3 @@
import React from 'react' import React from 'react'
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw' import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
@@ -16,114 +15,95 @@ export const SHAPE_TYPES = {
furniture: 'exam-region-furniture', furniture: 'exam-region-furniture',
} as const } as const
export type ExamPdfPageTLShape = TLBaseBoxShape & { export type PdfPageTLShape = TLBaseBoxShape<typeof PDF_PAGE_SHAPE_TYPE, {
type: typeof PDF_PAGE_SHAPE_TYPE w: number
props: { w: number; h: number; src: string; pageNumber: number } h: number
} src: string
pageNumber: number
}>
export type ExamCanvasTLShape = TLBaseBoxShape & { export type ExamCanvasTLShape = TLBaseBoxShape<typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES], {
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
props: {
w: number w: number
h: number h: number
label: string label: string
kind: ExamCanvasShapeKind kind: ExamCanvasShapeKind
domainId: string
maxMarks?: number maxMarks?: number
responseForm?: string responseForm?: string
contextType?: string contextType?: string
questionId?: string | null questionId?: string | null
domainId?: string }>
}
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
} }
type CanvasPaletteEntry = { function makeDomainId() {
stroke: string if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
fill: string return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
darkStroke: string (Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
darkFill: string )
dash?: string
label: string
icon: string
role: string
} }
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' },
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' },
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' },
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' },
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' },
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' },
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' },
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
}
const shapeCss = `
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
`
function renderShape(shape: ExamCanvasTLShape) { function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind const kind = shape.props.kind
const p = canvasShapePalette[kind] ?? canvasShapePalette.response const p = palette[kind] ?? palette.response
const isBoundary = kind === 'boundary' const isBoundary = kind === 'boundary'
return ( return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}> <HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<style>{shapeCss}</style> <div style={{
<div width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
style={{
'--exam-light-stroke': p.stroke,
'--exam-light-fill': p.fill,
'--exam-dark-stroke': p.darkStroke,
'--exam-dark-fill': p.darkFill,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10, borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif', background: isBoundary ? 'transparent' : p.fill, color: p.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 ? 'none' : '0 10px 22px rgba(15,23,42,0.08)', overflow: 'hidden'
} as React.CSSProperties} }}>
aria-label={`${p.label}: ${p.role}`} <span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
title={`${p.label}: ${p.role}`}
>
<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">{p.icon}</span>
{shape.props.label || p.label} {shape.props.label || p.label}
</span> </span>
{!isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>} {!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>}
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
</div> </div>
</HTMLContainer> </HTMLContainer>
) )
} }
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
const p = canvasShapePalette[kind] const p = palette[kind]
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined } return { w, h, label: p.label, kind, domainId: makeDomainId(), responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
} }
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) } class PdfPageUtil extends BaseBoxShapeUtil<PdfPageTLShape> {
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
static override type = PDF_PAGE_SHAPE_TYPE static override type = PDF_PAGE_SHAPE_TYPE
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number } static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } } override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
override canEdit() { return false } override canEdit() { return false }
override component(shape: ExamPdfPageTLShape) { override canResize() { return false }
override hideResizeHandles() { return true }
override hideRotateHandle() { return true }
override component(shape: PdfPageTLShape) {
return ( return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}> <HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} /> <img
alt={`PDF page ${shape.props.pageNumber}`}
src={shape.props.src}
draggable={false}
style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', boxShadow: '0 12px 26px rgba(15,23,42,0.22)', background: '#fff' }}
/>
</HTMLContainer> </HTMLContainer>
) )
} }
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
} }
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, domainId: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } } class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } }
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary } class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part } class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
@@ -137,10 +117,6 @@ class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
export function isPdfPageShape(type: string): boolean {
return type === PDF_PAGE_SHAPE_TYPE
}
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null { export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type) const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
+63 -35
View File
@@ -1,47 +1,75 @@
import * as pdfjsLib from "pdfjs-dist" import type { Editor } from '@tldraw/tldraw'
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url" import * as pdfjsLib from 'pdfjs-dist'
import pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs?url'
import { PAGE_WIDTH } from "../../../utils/exam-canvas/model" import { examRepository } from '../../../services/exam/examRepository'
import { PAGE_GAP, PAGE_WIDTH, type PdfPageLayout } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, type PdfPageTLShape } from './examCanvasShapes'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
export interface PdfPageImage { export interface PdfBackdropPage {
pageNumber: number page: number
src: string src: string
width: number layout: PdfPageLayout
height: number
} }
export async function loadPdfPageImages( export interface PdfBackdropResult {
pdfBytes: ArrayBuffer, pages: PdfBackdropPage[]
targetWidth = PAGE_WIDTH, pageLayouts: PdfPageLayout[]
onPageReady?: (pages: PdfPageImage[]) => void, }
): Promise<PdfPageImage[]> {
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise export async function renderPdfBackdrop(templateId: string): Promise<PdfBackdropResult> {
const pages: PdfPageImage[] = [] const bytes = await examRepository.getTemplateSourcePdf(templateId)
// Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(bytes) })
// for a typical 36-page exam paper. const pdf = await loadingTask.promise
const canvas = document.createElement("canvas") const pages: PdfBackdropPage[] = []
let y = 0
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const page = await pdf.getPage(pageNumber) const page = await pdf.getPage(pageNumber)
const baseViewport = page.getViewport({ scale: 1 }) const nativeViewport = page.getViewport({ scale: 1 })
const scale = targetWidth / baseViewport.width const layoutScale = PAGE_WIDTH / nativeViewport.width
const viewport = page.getViewport({ scale }) const renderScale = layoutScale * Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.ceil(viewport.width) const renderViewport = page.getViewport({ scale: renderScale })
canvas.height = Math.ceil(viewport.height) const canvas = document.createElement('canvas')
const context = canvas.getContext("2d") const context = canvas.getContext('2d')
if (!context) throw new Error("Unable to create PDF render canvas") if (!context) throw new Error('Could not create 2D canvas for PDF rendering')
context.clearRect(0, 0, canvas.width, canvas.height) canvas.width = Math.ceil(renderViewport.width)
await page.render({ canvasContext: context, viewport }).promise canvas.height = Math.ceil(renderViewport.height)
pages.push({ await page.render({ canvasContext: context, viewport: renderViewport }).promise
pageNumber,
src: canvas.toDataURL("image/png"), const layout: PdfPageLayout = {
width: canvas.width, page: pageNumber,
height: canvas.height, x: 0,
}) y,
onPageReady?.([...pages]) w: PAGE_WIDTH,
h: nativeViewport.height * layoutScale,
}
pages.push({ page: pageNumber, src: canvas.toDataURL('image/jpeg', 0.9), layout })
y += layout.h + PAGE_GAP
} }
return pages await pdf.destroy()
return { pages, pageLayouts: pages.map((p) => p.layout) }
}
export function replacePdfPageShapes(editor: Editor, pages: PdfBackdropPage[]) {
const old = editor.getCurrentPageShapes().filter((shape) => shape.type === PDF_PAGE_SHAPE_TYPE).map((shape) => shape.id)
if (old.length) editor.deleteShapes(old)
if (!pages.length) return
editor.createShapes(pages.map((page) => ({
id: `shape:pdf-page-${page.page}` as PdfPageTLShape['id'],
type: PDF_PAGE_SHAPE_TYPE,
x: page.layout.x,
y: page.layout.y,
isLocked: true,
props: {
w: page.layout.w,
h: page.layout.h,
src: page.src,
pageNumber: page.page,
},
})))
} }
-3
View File
@@ -9,7 +9,6 @@ import {
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School, ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { ResultsWidget } from '../exam';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api'; const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
@@ -255,8 +254,6 @@ const ClassDetailPage: React.FC = () => {
</Alert> </Alert>
)} )}
<ResultsWidget classId={cls.id} className={cls.name} />
{/* Tabs */} {/* Tabs */}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}> <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tab label={`Students (${cls.student_count})`} /> <Tab label={`Students (${cls.student_count})`} />
+7 -199
View File
@@ -8,25 +8,12 @@
import axios from 'axios'; import axios from 'axios';
import { API_BASE } from '../../config/apiConfig'; import { API_BASE } from '../../config/apiConfig';
import { logger } from '../../debugConfig';
import { supabase } from '../../supabaseClient'; import { supabase } from '../../supabaseClient';
import type { import type {
BatchQueueResponse,
BatchResultsResponse,
CreateBatchPayload,
CreateTemplatePayload, CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate, ExamTemplate,
ExamTemplateDetail, ExamTemplateDetail,
MarkingBatch,
MarkUpsertPayload,
Neo4jSyncResult,
PatchQuestionPayload,
SpecPoint,
TemplateReplacePayload, TemplateReplacePayload,
UpdateTemplateMetaPayload,
} from '../../types/exam.types'; } from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`; const EXAM_BASE = `${API_BASE}/api/exam`;
@@ -39,86 +26,6 @@ async function authHeaders(): Promise<Record<string, string>> {
return { Authorization: `Bearer ${session.access_token}` }; return { Authorization: `Bearer ${session.access_token}` };
} }
function newUuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16);
const v = c === 'x' ? r : (r % 4) + 8;
return v.toString(16);
});
}
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
return {
id: idMap?.get(q.id) ?? q.id,
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
label: q.label,
order: q.order,
max_marks: q.max_marks,
answer_type: q.answer_type,
mcq_options: q.mcq_options,
mark_scheme: q.mark_scheme ?? {},
is_container: q.is_container,
spec_ref: q.spec_ref,
bounds: q.bounds ?? null,
page: q.page ?? null,
};
}
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : r.id,
question_id: idMap?.get(r.question_id) ?? r.question_id,
page: r.page,
bounds: r.bounds,
kind: r.kind,
response_form: r.response_form,
context_type: r.context_type ?? null,
source: r.source,
confirmed: r.confirmed,
confidence: r.confidence,
};
}
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : b.id,
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
label: b.label,
page_index: b.page_index,
y: b.y,
bounds: b.bounds,
source: b.source,
confirmed: b.confirmed,
};
}
async function replaceTemplate(
templateId: string,
detail: ExamTemplateDetail,
meta?: UpdateTemplateMetaPayload,
duplicateIds = false,
): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const idMap = new Map<string, string>();
if (duplicateIds) {
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
}
const res = await axios.put<ExamTemplateDetail>(
`${EXAM_BASE}/templates/${templateId}`,
{
meta,
questions: detail.questions.map((q) => questionPayload(q, idMap)),
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
},
{ headers },
);
return res.data;
}
export const examRepository = { export const examRepository = {
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> { async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
const headers = await authHeaders(); const headers = await authHeaders();
@@ -135,127 +42,28 @@ export const examRepository = {
return res.data; return res.data;
}, },
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
const headers = await authHeaders();
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
headers,
responseType: 'arraybuffer',
});
return res.data;
},
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> { async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
const headers = await authHeaders(); const headers = await authHeaders();
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers }); const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
return res.data; return res.data;
}, },
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
return res.data;
},
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
const detail = await this.getTemplate(templateId);
const created = await this.createTemplate({
title,
subject: detail.subject ?? undefined,
exam_id: detail.exam_id ?? undefined,
exam_code: detail.exam_code ?? undefined,
source_file_id: detail.source_file_id ?? undefined,
page_count: detail.page_count ?? undefined,
institute_id: detail.institute_id,
});
try {
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
} catch (error) {
try {
await this.archiveTemplate(created.id);
} catch (archiveError) {
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
templateId: created.id,
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
});
}
throw error;
}
},
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> { async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
const headers = await authHeaders(); const headers = await authHeaders();
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers }); const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
return res.data; return res.data;
}, },
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
const headers = await authHeaders();
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { headers, responseType: 'arraybuffer' });
return res.data;
},
async archiveTemplate(templateId: string): Promise<void> { async archiveTemplate(templateId: string): Promise<void> {
const headers = await authHeaders(); const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
}, },
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
const headers = await authHeaders();
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
return res.data;
},
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
const headers = await authHeaders();
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
{ headers, params: search ? { q: search } : undefined },
);
if (Array.isArray(res.data)) return res.data;
return res.data.points ?? [];
},
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
const headers = await authHeaders();
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
return res.data;
},
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
const headers = await authHeaders();
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
return res.data;
},
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
const headers = await authHeaders();
const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
headers,
params: {
include_archived: params.includeArchived ?? false,
template_id: params.templateId,
},
});
return res.data.batches ?? [];
},
async getBatchQueue(batchId: string): Promise<BatchQueueResponse> {
const headers = await authHeaders();
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
return res.data;
},
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
const headers = await authHeaders();
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
return res.data;
},
async getBatchCsv(batchId: string): Promise<string> {
const headers = await authHeaders();
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
return res.data;
},
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
const headers = await authHeaders();
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
return res.data;
},
}; };
export default examRepository; export default examRepository;
+2 -143
View File
@@ -31,49 +31,6 @@ export interface CreateTemplatePayload {
institute_id?: string; institute_id?: string;
} }
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
export interface MarkSchemePoint {
mark: number;
text: string;
}
export interface MarkSchemeLevel {
level: string;
min: number;
max: number;
descriptor: string;
}
export interface MarkSchemePart {
label: string;
marks: number;
guidance: string;
}
export interface MarkSchemeChecklistItem {
text: string;
marks: number;
}
export interface MarkScheme {
type?: MarkSchemeType;
points?: MarkSchemePoint[];
levels?: MarkSchemeLevel[];
parts?: MarkSchemePart[];
checklist?: MarkSchemeChecklistItem[];
text?: string;
notes?: string;
[key: string]: unknown;
}
export interface UpdateTemplateMetaPayload {
title?: string;
subject?: string | null;
page_count?: number | null;
status?: ExamTemplateStatus;
}
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */ /** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
export interface ExamQuestion { export interface ExamQuestion {
id: string; id: string;
@@ -84,28 +41,20 @@ export interface ExamQuestion {
max_marks: number; max_marks: number;
answer_type: string | null; answer_type: string | null;
mcq_options: unknown | null; mcq_options: unknown | null;
mark_scheme: MarkScheme; mark_scheme: Record<string, unknown>;
is_container: boolean; is_container: boolean;
spec_ref: string | null; spec_ref: string | null;
bounds?: Record<string, number> | null; bounds?: Record<string, number> | null;
page?: number | null; page?: number | null;
} }
export type ExamResponseAreaKind =
| 'response'
| 'context'
| 'question_number'
| 'mark_area'
| 'reference'
| 'furniture';
export interface ExamResponseArea { export interface ExamResponseArea {
id: string; id: string;
question_id: string; question_id: string;
template_id: string; template_id: string;
page: number; page: number;
bounds: Record<string, number>; bounds: Record<string, number>;
kind: ExamResponseAreaKind; kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
response_form: string | null; response_form: string | null;
context_type?: string | null; context_type?: string | null;
source: 'manual' | 'ai'; source: 'manual' | 'ai';
@@ -176,93 +125,3 @@ export interface TemplateReplacePayload {
confirmed?: boolean; confirmed?: boolean;
}>; }>;
} }
export interface PatchQuestionPayload {
label?: string;
order?: number;
max_marks?: number;
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
mcq_options?: unknown;
mark_scheme?: MarkScheme;
is_container?: boolean;
spec_ref?: string | null;
}
export interface SpecPoint {
uid?: string;
uuid_string?: string;
ref: string;
description: string;
spec_code: string;
exam_board_code?: string;
}
export interface Neo4jSyncResult {
status: string;
projection?: Record<string, unknown>;
}
export interface MarkingBatch {
id: string;
template_id: string;
class_id: string | null;
institute_id: string;
teacher_id: string;
title: string | null;
status: 'open' | 'closed' | 'archived' | string;
created_at: string;
updated_at?: string;
submission_count?: number;
}
export interface StudentSubmission {
id: string;
batch_id: string;
student_id: string | null;
student_name: string | null;
status: 'absent' | 'unmatched' | 'matched' | 'marking' | 'complete' | string;
storage_path?: string | null;
mark_entry_count?: number;
}
export interface BatchQueueResponse {
batch: MarkingBatch;
submissions: StudentSubmission[];
progress: {
total: number;
absent: number;
complete: number;
in_progress: number;
};
}
export interface ExamResultRow {
submission_id: string;
student_id: string | null;
student_name: string | null;
status: string | null;
marks: Record<string, number | null | undefined>;
total: number | null;
}
export interface BatchResultsResponse {
batch: MarkingBatch;
questions: Array<Pick<ExamQuestion, 'id' | 'label' | 'max_marks' | 'order'>>;
results: ExamResultRow[];
}
export interface CreateBatchPayload {
template_id: string;
class_id?: string;
title?: string;
}
export interface MarkUpsertPayload {
submission_id: string;
question_id: string;
awarded_marks: number;
mark_scheme_detail?: Record<string, unknown>;
annotation_shape_ids?: unknown;
comment?: string;
confirmed?: boolean;
}
+35 -28
View File
@@ -1,65 +1,72 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { ExamTemplateDetail } from '../../types/exam.types' import type { ExamTemplateDetail } from '../../types/exam.types'
import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model' import { serializeCanvasShapes, shapesFromTemplate, type PdfPageLayout } from './model'
const template: ExamTemplateDetail = { const template: ExamTemplateDetail = {
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1, id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
} }
const ids = {
top: '11111111-1111-4111-8111-111111111111',
bottom: '22222222-2222-4222-8222-222222222222',
main: '33333333-3333-4333-8333-333333333333',
part: '44444444-4444-4444-8444-444444444444',
response: '55555555-5555-4555-8555-555555555555',
}
describe('exam setup canvas serialization', () => { describe('exam setup canvas serialization', () => {
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => { it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
const payload = serializeCanvasShapes(template, [ const payload = serializeCanvasShapes(template, [
{ id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' }, { id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
{ id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' }, { id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
{ id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 }, { id: ids.part, kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
{ id: 'resp-1', kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' }, { id: ids.response, kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
]) ])
const main = payload.questions.find((q) => q.is_container) const main = payload.questions.find((q) => q.is_container)
const part = payload.questions.find((q) => !q.is_container) const part = payload.questions.find((q) => q.id === ids.part)
expect(main?.label).toBe('Q1') expect(main?.label).toBe('Q1')
expect(part?.parent_id).toBe(main?.id) expect(part?.parent_id).toBe(main?.id)
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 }) expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
expect(payload.response_areas[0]).toMatchObject({ question_id: part?.id, kind: 'response', response_form: 'lines' }) expect(payload.response_areas[0]).toMatchObject({ id: ids.response, question_id: ids.part, kind: 'response', response_form: 'lines' })
expect(payload.boundaries).toHaveLength(2) expect(payload.boundaries).toHaveLength(2)
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true) expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true)
expect(payload.questions.every((q) => isUuid(q.id))).toBe(true)
expect(payload.response_areas.every((r) => isUuid(r.id))).toBe(true)
expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true)
}) })
it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => { it('uses real PDF page layouts to save visual page numbers', () => {
const pages = [ const layouts: PdfPageLayout[] = [
{ pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 }, { page: 1, x: 0, y: 0, w: 780, h: 1000 },
{ pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 }, { page: 2, x: 0, y: 1040, w: 780, h: 980 },
] ]
expect(pageForY(1050, pages)).toBe(2)
const payload = serializeCanvasShapes(template, [ const payload = serializeCanvasShapes(template, [
{ id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' }, { id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 1060, w: 700, h: 8, label: 'Q2 start' },
{ id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' }, { id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 1800, w: 700, h: 8, label: 'Q2 end' },
{ id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' }, { id: ids.part, kind: 'part', x: 100, y: 1120, w: 400, h: 220, label: 'Q2(a)', maxMarks: 3 },
{ id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 }, { id: ids.response, kind: 'response', x: 130, y: 1250, w: 300, h: 90, responseForm: 'lines' },
], pages) ], layouts)
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true) expect(payload.boundaries.map((b) => b.page_index)).toEqual([1, 1])
expect(payload.questions.find((q) => q.id === ids.part)?.page).toBe(2)
expect(payload.response_areas[0].page).toBe(2) expect(payload.response_areas[0].page).toBe(2)
expect(payload.meta?.page_count).toBe(2)
}) })
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => { it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
const shapes = shapesFromTemplate({ const shapes = shapesFromTemplate({
...template, ...template,
questions: [ questions: [
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null }, { id: ids.main, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 }, { id: ids.part, template_id: 'tpl-1', parent_id: ids.main, label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
], ],
response_areas: [ response_areas: [
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null }, { id: ids.response, question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null }, { id: '66666666-6666-4666-8666-666666666666', question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
], ],
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }], boundaries: [{ id: ids.top, template_id: 'tpl-1', question_id: ids.main, label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
}) })
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response']) expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 }) expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: ids.part, x: 1, y: 2, w: 3, h: 4 })
}) })
}) })
+50 -51
View File
@@ -1,19 +1,18 @@
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types' import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
export const PAGE_HEIGHT = 1100
export const PAGE_WIDTH = 780 export const PAGE_WIDTH = 780
export const PAGE_GAP = 0 export const DEFAULT_PAGE_HEIGHT = 1100
export const PAGE_GAP = 40
export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number } // Back-compat alias for S4-9a callers/tests. Real PDF pages use PdfPageLayout heights.
export const PAGE_HEIGHT = DEFAULT_PAGE_HEIGHT + PAGE_GAP
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture' export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
export interface CanvasBounds { x: number; y: number; w: number; h: number } export interface CanvasBounds { x: number; y: number; w: number; h: number }
export interface PdfPageLayout { page: number; x: number; y: number; w: number; h: number }
export interface ExamCanvasShapeModel { export interface ExamCanvasShapeModel {
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
id: string id: string
kind: ExamCanvasShapeKind kind: ExamCanvasShapeKind
x: number x: number
@@ -28,41 +27,29 @@ export interface ExamCanvasShapeModel {
questionId?: string | null questionId?: string | null
} }
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number { export function fallbackPageLayouts(pageCount = 4): PdfPageLayout[] {
if (pages?.length) { return Array.from({ length: Math.max(pageCount, 1) }, (_, i) => ({
const hit = pages.find((page) => y >= page.y && y <= page.y + page.h) page: i + 1,
if (hit) return hit.pageNumber x: 0,
const nearest = pages.reduce((best, page) => { y: i * PAGE_HEIGHT,
const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h))) w: PAGE_WIDTH,
return dy < best.dy ? { page, dy } : best h: DEFAULT_PAGE_HEIGHT,
}, { page: pages[0], dy: Number.POSITIVE_INFINITY }) }))
return nearest.page.pageNumber
}
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
} }
export function pageTop(page: number, pages?: CanvasPageGeometry[]): number { export function pageForY(y: number, layouts?: PdfPageLayout[]): number {
const hit = pages?.find((p) => p.pageNumber === page) if (!layouts?.length) return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP)) const hit = layouts.find((layout) => y >= layout.y && y <= layout.y + layout.h)
if (hit) return hit.page
const nearest = layouts.reduce((best, layout) => {
const dist = y < layout.y ? layout.y - y : y - (layout.y + layout.h)
return dist < best.dist ? { page: layout.page, dist } : best
}, { page: layouts[0].page, dist: Number.POSITIVE_INFINITY })
return nearest.page
} }
function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number { export function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, layouts?: PdfPageLayout[]): number {
return pageForY(shape.y + shape.h / 2, pages) return pageForY(shape.y + shape.h / 2, layouts)
}
export function isUuid(value: string | null | undefined): value is string {
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
export function newDomainId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16)
const v = c === 'x' ? r : (r % 4) + 8
return v.toString(16)
})
} }
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds { function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
@@ -84,10 +71,21 @@ function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, s
return cy >= minY && cy <= maxY return cy >= minY && cy <= maxY
} }
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload { function uuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
)
}
function stableUuid(id?: string | null): string {
return id && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ? id : uuid()
}
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pageLayouts?: PdfPageLayout[]): TemplateReplacePayload {
const orderedBoundaries = shapes const orderedBoundaries = shapes
.filter((s) => s.kind === 'boundary') .filter((s) => s.kind === 'boundary')
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y)) .sort((a, b) => (pageForShape(a, pageLayouts) - pageForShape(b, pageLayouts)) || (a.y - b.y))
const parts = shapes.filter((s) => s.kind === 'part') const parts = shapes.filter((s) => s.kind === 'part')
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
@@ -100,51 +98,52 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
const bottom = orderedBoundaries[i + 1] const bottom = orderedBoundaries[i + 1]
if (!top || !bottom) break if (!top || !bottom) break
const qNum = bands.length + 1 const qNum = bands.length + 1
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId() const questionId = stableUuid(top.questionId || bottom.questionId)
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}` const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} }) questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
bands.push({ questionId, top, bottom }) bands.push({ questionId, top, bottom })
for (const b of [top, bottom]) { for (const b of [top, bottom]) {
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true }) boundaries.push({ id: stableUuid(b.id), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pageLayouts) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
} }
} }
const partQuestionIds = new Map<string, string>() const partQuestionIds = new Map<string, string>()
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => { parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part)) const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId() const qid = stableUuid(part.questionId || part.id)
partQuestionIds.set(part.id, qid) partQuestionIds.set(part.id, qid)
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) }) questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pageLayouts) })
}) })
const response_areas: TemplateReplacePayload['response_areas'] = [] const response_areas: TemplateReplacePayload['response_areas'] = []
for (const region of regions) { for (const region of regions) {
const containingPart = parts.find((part) => contains(bounds(part), bounds(region))) const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0] const regionPage = pageForShape(region, pageLayouts)
const fallbackPart = parts.find((part) => pageForShape(part, pageLayouts) === regionPage) ?? parts[0]
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
if (!questionId) continue if (!questionId) continue
const kind = region.kind as ExamCanvasRegionKind const kind = region.kind as ExamCanvasRegionKind
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null }) response_areas.push({ id: stableUuid(region.id), question_id: questionId, page: regionPage, bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
} }
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries } return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: pageLayouts?.length ?? template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
} }
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] { export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] {
const shapes: ExamCanvasShapeModel[] = [] const shapes: ExamCanvasShapeModel[] = []
const questions = new Map(detail.questions.map((q) => [q.id, q])) const questions = new Map(detail.questions.map((q) => [q.id, q]))
for (const b of detail.boundaries ?? []) { for (const b of detail.boundaries ?? []) {
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 } const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
shapes.push({ id: b.id, kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id }) shapes.push({ id: stableUuid(b.id), kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
} }
for (const q of detail.questions ?? []) { for (const q of detail.questions ?? []) {
if (q.is_container || !q.bounds) continue if (q.is_container || !q.bounds) continue
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id }) shapes.push({ id: stableUuid(q.id), kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
} }
for (const r of detail.response_areas ?? []) { for (const r of detail.response_areas ?? []) {
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 } const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 }
const q = questions.get(r.question_id) const q = questions.get(r.question_id)
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id }) shapes.push({ id: stableUuid(r.id), kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
} }
return shapes return shapes
} }