diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx index 500c45f..8c28b41 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,8 @@ 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 { useAuth } from '../../contexts/AuthContext'; import { examRepository } from '../../services/exam/examRepository'; @@ -34,6 +36,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 +90,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 +114,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 +214,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 +228,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 +252,118 @@ 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/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index 1b5160d..e7d8d99 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -8,11 +8,16 @@ import axios from 'axios'; import { API_BASE } from '../../config/apiConfig'; +import { logger } from '../../debugConfig'; import { supabase } from '../../supabaseClient'; import type { CreateTemplatePayload, + ExamBoundary, + ExamQuestion, + ExamResponseArea, ExamTemplate, ExamTemplateDetail, + UpdateTemplateMetaPayload, } from '../../types/exam.types'; const EXAM_BASE = `${API_BASE}/api/exam`; @@ -25,6 +30,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(); @@ -47,6 +132,38 @@ export const examRepository = { 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 archiveTemplate(templateId: string): Promise { const headers = await authHeaders(); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 017e0d1..48312d4 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -31,6 +31,13 @@ export interface CreateTemplatePayload { institute_id?: string; } +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; @@ -44,16 +51,27 @@ export interface ExamQuestion { mark_scheme: Record; 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;