502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
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]->(: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;
|