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 = { 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dialog, setDialog] = useState(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(); 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 ( Exam Marker Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need. {error && ( setError(null)}> {error} )} {loading ? ( ) : templates.length === 0 ? ( No exam templates yet Create your first named template to start mapping an exam paper. ) : ( {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()} ); })} ))} )} {dialogTitle} 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'} /> ); }; export default ExamDashboardPage;