diff --git a/Dockerfile b/Dockerfile index 0a5c0ba..7f87db7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,9 @@ FROM nginx:alpine # Copy built files 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 RUN echo 'server { \ listen 3000; \ diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx index a4346e5..12fc485 100644 --- a/src/AppRoutes.admin.test.tsx +++ b/src/AppRoutes.admin.test.tsx @@ -31,7 +31,14 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () =>
Public Not Found< vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () =>
Public Home
})); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single Player
})); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
})); -vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
})); +vi.mock('./pages/exam', () => ({ + ExamDashboardPage: () =>
Exam Marker
, + ExamTemplateSetupPage: () =>
Exam Template Setup
, + MarkSchemePage: () =>
Mark Scheme editor
, + ExamMarkingPage: () =>
Exam Marking
, + ExamResultsPage: () =>
Exam Results
, + ResultsWidget: () =>
Results Widget
, +})); vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
})); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
})); @@ -122,3 +129,22 @@ describe('/admin route authorization', () => { 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: 'teacher@example.com', user_type: 'email_teacher' }, + user_role: 'email_teacher', + accessToken: 'token', + })); + + renderAt('/exam-marker/template-123/marks'); + + expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument(); + }); +}); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index e9d103a..f9139a2 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage'; import SignupPage from './pages/auth/signupPage'; import SinglePlayerPage from './pages/tldraw/singlePlayerPage'; import MultiplayerUser from './pages/tldraw/multiplayerUser'; -import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage } from './pages/exam'; +import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam'; import { ErrorBoundary } from './components/ErrorBoundary'; import CalendarPage from './pages/user/calendarPage'; import SettingsPage from './pages/user/settingsPage'; @@ -184,6 +184,8 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx index 500c45f..3595c31 100644 --- a/src/pages/exam/ExamDashboardPage.tsx +++ b/src/pages/exam/ExamDashboardPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Alert, @@ -22,6 +22,9 @@ import { import AddIcon from '@mui/icons-material/Add'; import ArchiveIcon from '@mui/icons-material/Archive'; 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 { examRepository } from '../../services/exam/examRepository'; @@ -34,6 +37,51 @@ const STATUS_COLOR: Record = 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 navigate = useNavigate(); const { bootstrapData } = useAuth(); @@ -43,8 +91,9 @@ const ExamDashboardPage: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [createOpen, setCreateOpen] = useState(false); - const [title, setTitle] = useState(''); + const [dialog, setDialog] = useState(null); + const [templateName, setTemplateName] = useState(''); + const [version, setVersion] = useState('v1'); const [subject, setSubject] = useState(''); const [saving, setSaving] = useState(false); @@ -66,33 +115,99 @@ const ExamDashboardPage: React.FC = () => { void load(); }, [load, instituteId]); - const handleCreate = async () => { - if (!title.trim()) return; + const groupedTemplates = useMemo(() => { + const groups = new Map(); + 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); try { - const created = await examRepository.createTemplate({ - title: title.trim(), - subject: subject.trim() || undefined, - institute_id: instituteId, + if (dialog.mode === 'create') { + const created = await examRepository.createTemplate({ + title, + subject: subject.trim() || undefined, + institute_id: instituteId, + }); + setDialog(null); + 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, }); - setCreateOpen(false); - setTitle(''); - setSubject(''); - navigate(`/exam-marker/${created.id}/setup`); + setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t))); + setDialog(null); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - logger.error('cc-exam-marker', 'Create template failed', { message: msg }); + logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode }); setError(msg); } finally { setSaving(false); } }; - const handleArchive = async (id: string, ev: React.MouseEvent) => { + const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => { 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 { - await examRepository.archiveTemplate(id); - setTemplates((prev) => prev.filter((t) => t.id !== id)); + await examRepository.archiveTemplate(template.id); + setTemplates((prev) => prev.filter((t) => t.id !== template.id)); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.error('cc-exam-marker', 'Archive failed', { message: msg }); @@ -100,6 +215,12 @@ const ExamDashboardPage: React.FC = () => { } }; + const dialogTitle = dialog?.mode === 'edit' + ? 'Rename template / edit version' + : dialog?.mode === 'duplicate' + ? 'Duplicate as new version' + : 'New exam template'; + return ( @@ -108,11 +229,11 @@ const ExamDashboardPage: React.FC = () => { Exam Marker - - Build a template for an exam paper, then run marking batches against your classes. + + Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need. - @@ -132,73 +253,128 @@ const ExamDashboardPage: React.FC = () => { No exam templates yet - Create your first template to start mapping an exam paper. + Create your first named template to start mapping an exam paper. - ) : ( - - {templates.map((t) => ( - - navigate(`/exam-marker/${t.id}/setup`)} - > - - {t.title} - - handleArchive(t.id, e)} aria-label="archive template"> - - - - - {t.subject && ( - {t.subject} - )} - {t.exam_code && ( - {t.exam_code} - )} - - - - {new Date(t.updated_at).toLocaleDateString()} - - - - + + {groupedTemplates.map((group) => ( + + + {group.label} + + + + {group.templates.map((t) => { + const parsed = splitTemplateTitle(t.title); + return ( + + navigate(`/exam-marker/${t.id}/setup`)} + > + + + {parsed.name} + + + + + openEdit(t, e)} aria-label="rename template"> + + + + + openDuplicate(t, e)} aria-label="duplicate template"> + + + + + handleArchive(t, e)} aria-label="archive template"> + + + + + + {t.subject && ( + {t.subject} + )} + {t.exam_code && ( + {t.exam_code} + )} + + + + Updated {new Date(t.updated_at).toLocaleDateString()} + + + + + + + + ); + })} + + ))} - + )} - (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm"> - New exam template + + {dialogTitle} setTitle(e.target.value)} + label="Template name" + value={templateName} + onChange={(e) => setTemplateName(e.target.value)} fullWidth autoFocus required + helperText="User-facing name. Several templates can share the same paper." /> setVersion(e.target.value)} + fullWidth + helperText="Stored in the template title until the API grows a dedicated version column." + /> + setSubject(e.target.value)} fullWidth + disabled={dialog?.mode === 'duplicate'} /> - - + diff --git a/src/pages/exam/MarkSchemePage.tsx b/src/pages/exam/MarkSchemePage.tsx new file mode 100644 index 0000000..39cd206 --- /dev/null +++ b/src/pages/exam/MarkSchemePage.tsx @@ -0,0 +1,501 @@ +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; + +interface PartDraft { + max_marks: string; + answer_type: 'written' | 'mcq' | 'short' | 'diagram'; + spec_ref: string; + schemeType: MarkSchemeType; + body: string; + notes: string; +} + +const ANSWER_TYPES: Array = ['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 { + 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(null); + const [selectedId, setSelectedId] = useState(null); + const [drafts, setDrafts] = useState({}); + const [specCode, setSpecCode] = useState(''); + const [specPoints, setSpecPoints] = useState([]); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(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) => { + 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 ; + } + + return ( + + + + + + Mark Scheme editor + + {template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points. + + + + {templateId && } + + + + + {error && setError(null)}>{error}} + {notice && setNotice(null)}>{notice}} + {specEndpointMissing && ( + + 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. + + )} + + + + + setSpecCode(e.target.value)} + fullWidth + helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker." + /> + + + + Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint). + + + + + + {parts.length === 0 ? ( + + This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes. + + ) : ( + + + + + Parts + setSelectedId(value)} + variant="scrollable" + sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }} + > + {parts.map((part) => ( + + Part {part.label} + + + {(drafts[part.id]?.spec_ref || part.spec_ref) && } + + + } + /> + ))} + + + + + + + {selected && draft && ( + + + + + + Part {selected.label} + + {selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'} + + + + + + + + updateDraft(selected.id, { max_marks: e.target.value })} + type="number" + inputProps={{ min: 0, step: 0.5 }} + fullWidth + /> + + + + Answer type + + + + + + Scheme form + + + + + + updateDraft(selected.id, { spec_ref: value?.ref ?? '' })} + getOptionLabel={(option) => `${option.ref} — ${option.description}`} + isOptionEqualToValue={(option, value) => option.ref === value.ref} + renderInput={(params) => ( + + )} + /> + + 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 + /> + + + + 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 + /> + + updateDraft(selected.id, { notes: e.target.value })} + multiline + minRows={3} + fullWidth + /> + + + + )} + + + )} + + + ); +}; + +export default MarkSchemePage; diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts index d9f4bcd..21f10c6 100644 --- a/src/pages/exam/index.ts +++ b/src/pages/exam/index.ts @@ -1,4 +1,6 @@ export { default as ExamDashboardPage } from './ExamDashboardPage'; +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'; diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx new file mode 100644 index 0000000..ba08234 --- /dev/null +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -0,0 +1,360 @@ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import HelpOutlineIcon from '@mui/icons-material/HelpOutline' +import SaveIcon from '@mui/icons-material/Save' +import MouseIcon from '@mui/icons-material/Mouse' +import '@tldraw/tldraw/tldraw.css' +import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw' +import axios from 'axios' + +import { ErrorBoundary } from '../../../components/ErrorBoundary' +import { logger } from '../../../debugConfig' +import { examRepository } from '../../../services/exam/examRepository' +import type { ExamTemplateDetail } from '../../../types/exam.types' +import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' +import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes' +import { loadPdfPageImages, PdfPageImage } from './pdfLoader' + +const TOOLS = [ + { id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' 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.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.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.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.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.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.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.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const }, +] + +const PAGE_START_X = 0 +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 applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) { + const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH + const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT + editor.setCameraOptions({ + constraints: { + bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 }, + padding: { x: 64, y: 64 }, + origin: { x: 0.5, y: 0 }, + initialZoom: 'fit-x-100', + baseZoom: 'default', + behavior: 'contain', + }, + isLocked: false, + }) +} + +function apiMessage(err: unknown): { message: string; conflict: boolean } { + if (axios.isAxiosError(err)) { + const detail = (err.response?.data as { detail?: string } | undefined)?.detail + if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' } + return { conflict: false, message: detail ?? err.message } + } + return { conflict: false, message: err instanceof Error ? err.message : String(err) } +} + +function stripShapePrefix(id: string) { + 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 { + const kind = shapeTypeToKind(shape.type) + if (!kind) return null + const s = shape as ExamCanvasTLShape + return { + id: domainIdForShape(s), + kind, + x: Number(s.x ?? 0), + y: Number(s.y ?? 0), + w: Number(s.props.w ?? 1), + h: Number(s.props.h ?? 1), + label: s.props.label, + maxMarks: s.props.maxMarks, + responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'], + contextType: s.props.contextType, + questionId: s.props.questionId ?? 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[]) { + if (!models.length) return + const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id) + if (existing.length) editor.deleteShapes(existing) + editor.createShapes(models.map((m) => ({ + id: createShapeId(m.id), + type: SHAPE_TYPES[m.kind], + x: m.x, + 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 }, + }))) +} + +function syncPdfPages(editor: Editor, pages: PdfPageImage[]) { + const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id) + if (existing.length) editor.deleteShapes(existing) + 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) { + const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) + if (current.length) return + 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: 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.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.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } }, + { id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } }, + ]) +} + +const ExamTemplateSetupInner: React.FC = () => { + const { templateId } = useParams<{ templateId: string }>() + const navigate = useNavigate() + const theme = useTheme() + const editorRef = useRef(null) + const pageGeometriesRef = useRef([]) + const [template, setTemplate] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) + const [error, setError] = useState(null) + const [conflict, setConflict] = useState(null) + const [activeTool, setActiveTool] = useState('select') + const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading') + const [pdfError, setPdfError] = useState(null) + const [guideOpen, setGuideOpen] = useState(false) + + const load = useCallback(async () => { + if (!templateId) return + setLoading(true); setError(null); setConflict(null) + try { + const detail = await examRepository.getTemplate(templateId) + 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) + } + applyDocViewConstraints(ed, partialPages) + } + 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 + if (editor) { + syncPdfPages(editor, pages) + loadShapes(editor, shapesFromTemplate(detail, geometries)) + bringDomainShapesToFront(editor) + applyDocViewConstraints(editor, pages) + editor.resetZoom() + } + setDirty(false) + } catch (e) { + const msg = apiMessage(e).message + logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg }) + setError(msg) + } finally { + setLoading(false) + } + }, [templateId]) + + useEffect(() => { void load() }, [load]) + + const save = useCallback(async () => { + const editor = editorRef.current + if (!editor || !templateId || !template) return + setSaving(true); setError(null); setConflict(null) + try { + ensureDomainIds(editor) + const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[] + const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current) + const saved = await examRepository.replaceTemplate(templateId, payload) + setTemplate(saved) + loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current)) + setDirty(false) + } catch (e) { + const msg = apiMessage(e) + if (msg.conflict) setConflict(msg.message); else setError(msg.message) + logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message }) + } finally { + setSaving(false) + } + }, [template, templateId]) + + const toolButtons = useMemo(() => TOOLS.map((tool) => ( + + + + )), [activeTool]) + + return ( + t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}> + + {/* Top bar — single compact line */} + + + navigate('/exam-marker')} size="small"> + + + {template?.title ?? 'Template setup'} + + + + + {/* Body row */} + + + {/* Left tool sidebar */} + + {toolButtons} + + + {/* Canvas area */} + + + { + editorRef.current = editor + editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) + editor.store.listen(() => setDirty(true), { scope: 'document' }) + applyDocViewConstraints(editor, []) + editor.resetZoom() + if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor) + bringDomainShapesToFront(editor) + }} + /> + + + {/* Guide toggle */} + + setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}> + + + + + {/* Guide panel — collapsible */} + + + Setup guide + + 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. + + + {(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => { + const p = canvasShapePalette[kind] + return + })} + + + Multi-page boundary pairing + Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span. + + PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'} + + + + + {/* Conflict alert */} + {conflict && setConflict(null)}>{conflict}} + + + + {loading && } + setError(null)}> setError(null)}>{error} + + ) +} + +const ExamTemplateSetupPage: React.FC = () => ( + Template setup canvas crashed. Reload the page and try again.}> + + +) + +export default ExamTemplateSetupPage diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx new file mode 100644 index 0000000..a58b748 --- /dev/null +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -0,0 +1,147 @@ + +import React from 'react' +import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw' +import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' + +export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page' + +export const SHAPE_TYPES = { + boundary: 'exam-boundary', + part: 'exam-part', + response: 'exam-region-response', + context: 'exam-region-context', + question_number: 'exam-region-question-number', + mark_area: 'exam-region-mark-area', + reference: 'exam-region-reference', + furniture: 'exam-region-furniture', +} as const + +export type ExamPdfPageTLShape = TLBaseBoxShape & { + type: typeof PDF_PAGE_SHAPE_TYPE + props: { w: number; h: number; src: string; pageNumber: number } +} + +export type ExamCanvasTLShape = TLBaseBoxShape & { + type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES] + props: { + w: number + h: number + label: string + kind: ExamCanvasShapeKind + maxMarks?: number + responseForm?: string + contextType?: string + questionId?: string | null + domainId?: string + } +} + +type CanvasPaletteEntry = { + stroke: string + fill: string + darkStroke: string + darkFill: string + dash?: string + label: string + icon: string + role: string +} + +export const canvasShapePalette: Record = { + 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) { + const kind = shape.props.kind + const p = canvasShapePalette[kind] ?? canvasShapePalette.response + const isBoundary = kind === 'boundary' + return ( + + +
+ + + {shape.props.label || p.label} + + {!isBoundary && shape.props.questionId && Attached} + {isBoundary && pair across pages} +
+
+ ) +} + +function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { + const p = canvasShapePalette[kind] + return { w, h, label: p.label, kind, 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) } +const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => + +class PdfPageUtil extends BaseBoxShapeUtil { + static override type = PDF_PAGE_SHAPE_TYPE + 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 canEdit() { return false } + override component(shape: ExamPdfPageTLShape) { + return ( + + {'PDF + + ) + } + override indicator(shape: ExamPdfPageTLShape) { return ind(shape) } +} +class BoundaryUtil extends BaseBoxShapeUtil { 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 { 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) } } +function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil { 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 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 ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response } +class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context } +class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number } +class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area } +class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference } +class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture } + +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 function isPdfPageShape(type: string): boolean { + return type === PDF_PAGE_SHAPE_TYPE +} + +export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null { + const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type) + return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null +} diff --git a/src/pages/exam/setup/pdfLoader.ts b/src/pages/exam/setup/pdfLoader.ts new file mode 100644 index 0000000..4e3bfae --- /dev/null +++ b/src/pages/exam/setup/pdfLoader.ts @@ -0,0 +1,47 @@ +import * as pdfjsLib from "pdfjs-dist" +import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url" + +import { PAGE_WIDTH } from "../../../utils/exam-canvas/model" + +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc + +export interface PdfPageImage { + pageNumber: number + src: string + width: number + height: number +} + +export async function loadPdfPageImages( + pdfBytes: ArrayBuffer, + targetWidth = PAGE_WIDTH, + onPageReady?: (pages: PdfPageImage[]) => void, +): Promise { + const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise + const pages: PdfPageImage[] = [] + // Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory + // for a typical 36-page exam paper. + const canvas = document.createElement("canvas") + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber) + const baseViewport = page.getViewport({ scale: 1 }) + const scale = targetWidth / baseViewport.width + const viewport = page.getViewport({ scale }) + canvas.width = Math.ceil(viewport.width) + canvas.height = Math.ceil(viewport.height) + const context = canvas.getContext("2d") + if (!context) throw new Error("Unable to create PDF render canvas") + context.clearRect(0, 0, canvas.width, canvas.height) + await page.render({ canvasContext: context, viewport }).promise + pages.push({ + pageNumber, + src: canvas.toDataURL("image/png"), + width: canvas.width, + height: canvas.height, + }) + onPageReady?.([...pages]) + } + + return pages +} diff --git a/src/pages/timetable/ClassDetailPage.tsx b/src/pages/timetable/ClassDetailPage.tsx index 277399f..96157cb 100644 --- a/src/pages/timetable/ClassDetailPage.tsx +++ b/src/pages/timetable/ClassDetailPage.tsx @@ -198,7 +198,7 @@ const ClassDetailPage: React.FC = () => { const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => { setActionError(null); - const res = await apiPost(`/database/timetable/enrollment-requests/${requestId}/respond`, { status: action === 'approve' ? 'approved' : 'rejected' }); + const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action }); if (res.status === 'ok') load(); else setActionError(res.detail || 'Action failed'); }; diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index b78ed4f..8f0b9e3 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -8,16 +8,25 @@ import axios from 'axios'; import { API_BASE } from '../../config/apiConfig'; +import { logger } from '../../debugConfig'; import { supabase } from '../../supabaseClient'; import type { BatchQueueResponse, BatchResultsResponse, CreateBatchPayload, CreateTemplatePayload, + ExamBoundary, + ExamQuestion, + ExamResponseArea, ExamTemplate, ExamTemplateDetail, MarkingBatch, MarkUpsertPayload, + Neo4jSyncResult, + PatchQuestionPayload, + SpecPoint, + TemplateReplacePayload, + UpdateTemplateMetaPayload, } from '../../types/exam.types'; const EXAM_BASE = `${API_BASE}/api/exam`; @@ -30,6 +39,86 @@ async function authHeaders(): Promise> { 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) { + 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, 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, 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 { + const headers = await authHeaders(); + const idMap = new Map(); + if (duplicateIds) { + detail.questions.forEach((q) => idMap.set(q.id, newUuid())); + } + const res = await axios.put( + `${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 = { async listTemplates(includeArchived = false): Promise { const headers = await authHeaders(); @@ -46,17 +135,86 @@ export const examRepository = { return res.data; }, + async getTemplateSourcePdf(templateId: string): Promise { + const headers = await authHeaders(); + const res = await axios.get(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { + headers, + responseType: 'arraybuffer', + }); + return res.data; + }, + async createTemplate(payload: CreateTemplatePayload): Promise { const headers = await authHeaders(); const res = await axios.post(`${EXAM_BASE}/templates`, payload, { headers }); return res.data; }, + async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise { + const headers = await authHeaders(); + const res = await axios.patch(`${EXAM_BASE}/templates/${templateId}`, meta, { headers }); + return res.data; + }, + + async duplicateTemplate(templateId: string, title: string): Promise { + 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 { + const headers = await authHeaders(); + const res = await axios.put(`${EXAM_BASE}/templates/${templateId}`, payload, { headers }); + return res.data; + }, async archiveTemplate(templateId: string): Promise { const headers = await authHeaders(); 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 { + 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 { + const headers = await authHeaders(); + const res = await axios.post(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers }); + return res.data; + }, + async createBatch(payload: CreateBatchPayload): Promise { const headers = await authHeaders(); const res = await axios.post(`${EXAM_BASE}/batches`, payload, { headers }); diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 32a419b..1b65186 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -31,6 +31,49 @@ export interface CreateTemplatePayload { 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). */ export interface ExamQuestion { id: string; @@ -41,19 +84,30 @@ export interface ExamQuestion { max_marks: number; answer_type: string | null; mcq_options: unknown | null; - mark_scheme: Record; + mark_scheme: MarkScheme; is_container: boolean; spec_ref: string | null; + bounds?: Record | null; + page?: number | null; } +export type ExamResponseAreaKind = + | 'response' + | 'context' + | 'question_number' + | 'mark_area' + | 'reference' + | 'furniture'; + export interface ExamResponseArea { id: string; question_id: string; template_id: string; page: number; bounds: Record; - kind: 'response' | 'context'; + kind: ExamResponseAreaKind; response_form: string | null; + context_type?: string | null; source: 'manual' | 'ai'; confirmed: boolean; confidence: number | null; @@ -77,6 +131,77 @@ export interface ExamTemplateDetail extends ExamTemplate { boundaries: ExamBoundary[]; } + +export interface TemplateReplacePayload { + meta?: { + title?: string; + subject?: string; + page_count?: number; + status?: ExamTemplateStatus; + }; + questions: Array<{ + id?: string; + parent_id?: string | null; + label: string; + order?: number; + max_marks?: number; + answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null; + mcq_options?: unknown | null; + mark_scheme?: Record; + is_container?: boolean; + spec_ref?: string | null; + bounds?: Record | null; + page?: number | null; + }>; + response_areas: Array<{ + id?: string; + question_id: string; + page: number; + bounds: Record; + kind: ExamResponseArea['kind']; + response_form?: string | null; + context_type?: string | null; + source?: 'manual' | 'ai'; + confirmed?: boolean; + confidence?: number | null; + }>; + boundaries: Array<{ + id?: string; + question_id?: string | null; + label?: string | null; + page_index: number; + y: number; + bounds?: Record | null; + source?: 'manual' | 'ai'; + 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; +} + export interface MarkingBatch { id: string; template_id: string; diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts new file mode 100644 index 0000000..52b40b7 --- /dev/null +++ b/src/utils/exam-canvas/model.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { ExamTemplateDetail } from '../../types/exam.types' +import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model' + +const template: ExamTemplateDetail = { + 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: [], +} + +describe('exam setup canvas serialization', () => { + it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => { + const payload = serializeCanvasShapes(template, [ + { id: 'b-top', kind: 'boundary', 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: 'part-1', 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' }, + ]) + + const main = payload.questions.find((q) => q.is_container) + const part = payload.questions.find((q) => !q.is_container) + expect(main?.label).toBe('Q1') + expect(part?.parent_id).toBe(main?.id) + 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.boundaries).toHaveLength(2) + 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', () => { + const pages = [ + { pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 }, + { pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 }, + ] + expect(pageForY(1050, pages)).toBe(2) + const payload = serializeCanvasShapes(template, [ + { id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' }, + { id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' }, + { id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' }, + { id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 }, + ], pages) + expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2) + expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true) + expect(payload.response_areas[0].page).toBe(2) + }) + + it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => { + const shapes = shapesFromTemplate({ + ...template, + 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: '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 }, + ], + 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: '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 }, + ], + 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 }], + }) + 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 }) + }) +}) diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts new file mode 100644 index 0000000..22bdb54 --- /dev/null +++ b/src/utils/exam-canvas/model.ts @@ -0,0 +1,150 @@ + +import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types' + +export const PAGE_HEIGHT = 1100 +export const PAGE_WIDTH = 780 +export const PAGE_GAP = 0 + +export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number } + +export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture' +export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind + +export interface CanvasBounds { x: number; y: number; w: number; h: number } + +export interface ExamCanvasShapeModel { + /** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */ + id: string + kind: ExamCanvasShapeKind + x: number + y: number + w: number + h: number + label?: string + maxMarks?: number + answerType?: 'written' | 'mcq' | 'short' | 'diagram' + responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks' + contextType?: string + questionId?: string | null +} + +export function pageForY(y: number, pages?: CanvasPageGeometry[]): number { + if (pages?.length) { + const hit = pages.find((page) => y >= page.y && y <= page.y + page.h) + if (hit) return hit.pageNumber + const nearest = pages.reduce((best, page) => { + const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h))) + return dy < best.dy ? { page, dy } : best + }, { 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 { + const hit = pages?.find((p) => p.pageNumber === page) + return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP)) +} + +function pageForShape(shape: Pick, pages?: CanvasPageGeometry[]): number { + return pageForY(shape.y + shape.h / 2, pages) +} + +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): CanvasBounds { + return { x: shape.x, y: shape.y, w: shape.w, h: shape.h } +} + +function contains(outer: CanvasBounds, inner: CanvasBounds): boolean { + const ox2 = outer.x + outer.w + const oy2 = outer.y + outer.h + const ix2 = inner.x + inner.w + const iy2 = inner.y + inner.h + return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2 +} + +function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean { + const minY = Math.min(top.y, bottom.y) + const maxY = Math.max(top.y, bottom.y) + const cy = shape.y + shape.h / 2 + return cy >= minY && cy <= maxY +} + +export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload { + const orderedBoundaries = shapes + .filter((s) => s.kind === 'boundary') + .sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y)) + const parts = shapes.filter((s) => s.kind === 'part') + const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') + + const questions: TemplateReplacePayload['questions'] = [] + const boundaries: TemplateReplacePayload['boundaries'] = [] + const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = [] + + for (let i = 0; i < orderedBoundaries.length; i += 2) { + const top = orderedBoundaries[i] + const bottom = orderedBoundaries[i + 1] + if (!top || !bottom) break + const qNum = bands.length + 1 + const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId() + 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: {} }) + bands.push({ questionId, 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 }) + } + } + + const partQuestionIds = new Map() + 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 qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId() + 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) }) + }) + + const response_areas: TemplateReplacePayload['response_areas'] = [] + for (const region of regions) { + const containingPart = parts.find((part) => contains(bounds(part), bounds(region))) + const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0] + const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined + if (!questionId) continue + 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 }) + } + + return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries } +} + +export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] { + const shapes: ExamCanvasShapeModel[] = [] + const questions = new Map(detail.questions.map((q) => [q.id, q])) + for (const b of detail.boundaries ?? []) { + 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 }) + } + for (const q of detail.questions ?? []) { + 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 }) + } + for (const r of detail.response_areas ?? []) { + const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 } + 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 }) + } + return shapes +}