- Add /exam-marker/:templateId/marks route with MarkSchemePage
- Per-Part mark scheme editor: points/levels/parts/checklist/free forms
- SpecPoint picker via GET /api/exam/specs/{spec_code}/points (falls back to manual spec_ref when endpoint 404s)
- Manual neo4j-sync button; ASSESSES edge verified in cc.public.exams
- Edit marks button on each template card in dashboard
- Merge-resolved: AppRoutes, index.ts, exam.types.ts, ExamDashboardPage (kept grouped UI + added Edit marks button)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Container,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
Grid,
|
|
IconButton,
|
|
Paper,
|
|
Stack,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
} from '@mui/material';
|
|
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';
|
|
import type { ExamTemplate } from '../../types/exam.types';
|
|
import { logger } from '../../debugConfig';
|
|
|
|
const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> = {
|
|
draft: 'warning',
|
|
ready: 'success',
|
|
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();
|
|
const instituteId = bootstrapData?.active_institute?.id ?? undefined;
|
|
|
|
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [dialog, setDialog] = useState<TemplateDialogState>(null);
|
|
const [templateName, setTemplateName] = useState('');
|
|
const [version, setVersion] = useState('v1');
|
|
const [subject, setSubject] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setTemplates(await examRepository.listTemplates());
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
logger.warn('cc-exam-marker', 'Failed to load templates', { message: msg });
|
|
setError(msg);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load, instituteId]);
|
|
|
|
const groupedTemplates = useMemo(() => {
|
|
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
|
|
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 {
|
|
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,
|
|
});
|
|
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', 'Template action failed', { message: msg, mode: dialog.mode });
|
|
setError(msg);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
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(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 });
|
|
setError(msg);
|
|
}
|
|
};
|
|
|
|
const dialogTitle = dialog?.mode === 'edit'
|
|
? 'Rename template / edit version'
|
|
: dialog?.mode === 'duplicate'
|
|
? 'Duplicate as new version'
|
|
: 'New exam template';
|
|
|
|
return (
|
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
|
<Stack spacing={4}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
|
<Box>
|
|
<Typography variant="h3" component="h1" gutterBottom>
|
|
Exam Marker
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
|
|
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
|
</Typography>
|
|
</Box>
|
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
|
New template
|
|
</Button>
|
|
</Box>
|
|
|
|
{error && (
|
|
<Alert severity="error" onClose={() => setError(null)}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
) : templates.length === 0 ? (
|
|
<Paper variant="outlined" sx={{ p: 6, textAlign: 'center' }}>
|
|
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
|
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Create your first named template to start mapping an exam paper.
|
|
</Typography>
|
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
|
New template
|
|
</Button>
|
|
</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}>
|
|
{group.templates.map((t) => {
|
|
const parsed = splitTemplateTitle(t.title);
|
|
return (
|
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
|
<Paper
|
|
elevation={2}
|
|
sx={{
|
|
p: 3,
|
|
height: '100%',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 1,
|
|
transition: 'box-shadow 120ms',
|
|
'&:hover': { boxShadow: 6 },
|
|
}}
|
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<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">
|
|
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
|
|
<ArchiveIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Stack>
|
|
</Box>
|
|
{t.subject && (
|
|
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
|
)}
|
|
{t.exam_code && (
|
|
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
|
)}
|
|
<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" />
|
|
<Typography variant="caption" color="text.secondary">
|
|
Updated {new Date(t.updated_at).toLocaleDateString()}
|
|
</Typography>
|
|
</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>
|
|
</Grid>
|
|
);
|
|
})}
|
|
</Grid>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
|
|
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
<DialogContent>
|
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
<TextField
|
|
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."
|
|
/>
|
|
<TextField
|
|
label="Version"
|
|
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}
|
|
onChange={(e) => setSubject(e.target.value)}
|
|
fullWidth
|
|
disabled={dialog?.mode === 'duplicate'}
|
|
/>
|
|
</Stack>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
|
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
|
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default ExamDashboardPage;
|