Compare commits
1 Commits
master
...
agent/s4-9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5f073eb91 |
68
Dockerfile
68
Dockerfile
@ -7,45 +7,18 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
|
# Vite bakes VITE_* values at build time, so compose must choose the env file
|
||||||
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
|
# during image build, not only at container runtime.
|
||||||
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
|
ARG ENV_FILE=.env
|
||||||
ARG VITE_API_BASE
|
COPY ${ENV_FILE} .env
|
||||||
ARG VITE_API_URL
|
|
||||||
ARG VITE_APP_NAME
|
# Run build with production mode
|
||||||
ARG VITE_APP_HMR_URL
|
RUN npm run build -- --mode production
|
||||||
ARG VITE_DEV
|
|
||||||
ARG VITE_FRONTEND_SITE_URL
|
|
||||||
ARG VITE_SEARCH_URL
|
|
||||||
ARG VITE_SUPABASE_ANON_KEY
|
|
||||||
ARG VITE_SUPABASE_URL
|
|
||||||
ARG VITE_SUPER_ADMIN_EMAIL
|
|
||||||
ARG VITE_TLSYNC_URL
|
|
||||||
ARG VITE_WHISPERLIVE_URL
|
|
||||||
# Run build with production mode. Keep these as build-step environment values
|
|
||||||
# rather than final-image ENV entries; Vite still embeds the public client config
|
|
||||||
# into the static bundle, but nginx image metadata does not need them.
|
|
||||||
RUN VITE_API_BASE="${VITE_API_BASE}" \
|
|
||||||
VITE_API_URL="${VITE_API_URL}" \
|
|
||||||
VITE_APP_NAME="${VITE_APP_NAME}" \
|
|
||||||
VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
|
|
||||||
VITE_DEV="${VITE_DEV}" \
|
|
||||||
VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
|
|
||||||
VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
|
|
||||||
VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
|
|
||||||
VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
|
|
||||||
VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
|
|
||||||
VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
|
|
||||||
VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
|
|
||||||
npm run build -- --mode production
|
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
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
|
# Create a simple nginx configuration
|
||||||
RUN echo 'server { \
|
RUN echo 'server { \
|
||||||
listen 3000; \
|
listen 3000; \
|
||||||
@ -55,33 +28,6 @@ RUN echo 'server { \
|
|||||||
expires -1; \
|
expires -1; \
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
||||||
} \
|
} \
|
||||||
location = /health { \
|
|
||||||
proxy_pass http://192.168.0.64:18000/health; \
|
|
||||||
proxy_set_header Host $host; \
|
|
||||||
proxy_set_header X-Real-IP $remote_addr; \
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
|
||||||
} \
|
|
||||||
location /__ccapi/ { \
|
|
||||||
proxy_pass http://192.168.0.64:18000/; \
|
|
||||||
proxy_set_header Host $host; \
|
|
||||||
proxy_set_header X-Real-IP $remote_addr; \
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
|
||||||
} \
|
|
||||||
location /__supabase/ { \
|
|
||||||
proxy_pass http://192.168.0.94:8000/; \
|
|
||||||
proxy_set_header Host $host; \
|
|
||||||
proxy_set_header X-Real-IP $remote_addr; \
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
|
||||||
proxy_set_header Upgrade $http_upgrade; \
|
|
||||||
proxy_set_header Connection "upgrade"; \
|
|
||||||
proxy_http_version 1.1; \
|
|
||||||
} \
|
|
||||||
location /api/ { \
|
|
||||||
proxy_pass http://192.168.0.64:18000/api/; \
|
|
||||||
proxy_set_header Host $host; \
|
|
||||||
proxy_set_header X-Real-IP $remote_addr; \
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
|
||||||
} \
|
|
||||||
location / { \
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
expires 30d; \
|
expires 30d; \
|
||||||
|
|||||||
@ -16,28 +16,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
# app-dev is served by nginx on the app host; browser API calls must stay
|
ENV_FILE: .env.dev
|
||||||
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
|
|
||||||
# strips that prefix before forwarding, preserving mixed backend routes
|
|
||||||
# such as /api/exam, /me/bootstrap, and /database/timetable.
|
|
||||||
# Supabase is likewise routed same-origin via Dockerfile's /__supabase
|
|
||||||
# proxy -> dev Supabase .94 (no CORS, browser-network-agnostic). The
|
|
||||||
# leading-slash URL is resolved against window.location.origin in
|
|
||||||
# supabaseClient.ts. Provide the matching .94 anon key as a build arg
|
|
||||||
# (VITE_SUPABASE_ANON_KEY) so it pairs with the .94 backend.
|
|
||||||
# .env.dev still points at the LAN API/Supabase for local Vite/dev tooling.
|
|
||||||
VITE_API_BASE: /__ccapi
|
|
||||||
VITE_API_URL: /__ccapi
|
|
||||||
VITE_SUPABASE_URL: /__supabase
|
|
||||||
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
|
||||||
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
|
||||||
VITE_DEV: ${VITE_DEV:-false}
|
|
||||||
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
|
||||||
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
|
||||||
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
|
||||||
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
|
||||||
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
|
||||||
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env.dev
|
- .env.dev
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -31,17 +31,11 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
|
|||||||
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
||||||
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
||||||
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
||||||
vi.mock('./pages/exam', () => ({
|
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
|
||||||
ExamDashboardPage: () => <div>Exam Marker</div>,
|
|
||||||
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
|
|
||||||
MarkSchemePage: () => <div>Mark Scheme editor</div>,
|
|
||||||
ExamMarkingPage: () => <div>Exam Marking</div>,
|
|
||||||
ExamResultsPage: () => <div>Exam Results</div>,
|
|
||||||
ResultsWidget: () => <div>Results Widget</div>,
|
|
||||||
}));
|
|
||||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||||
|
vi.mock('./pages/tldraw/ExamMarkerSpikePage', () => ({ default: () => <div>Exam Marker Spike</div> }));
|
||||||
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
||||||
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
||||||
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
||||||
@ -129,22 +123,3 @@ describe('/admin route authorization', () => {
|
|||||||
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
|
|||||||
import SignupPage from './pages/auth/signupPage';
|
import SignupPage from './pages/auth/signupPage';
|
||||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||||
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam';
|
import { ExamDashboardPage } from './pages/exam';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import CalendarPage from './pages/user/calendarPage';
|
import CalendarPage from './pages/user/calendarPage';
|
||||||
import SettingsPage from './pages/user/settingsPage';
|
import SettingsPage from './pages/user/settingsPage';
|
||||||
@ -16,6 +16,7 @@ import AdminDashboard from './pages/auth/adminPage';
|
|||||||
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
||||||
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
||||||
import DevPage from './pages/tldraw/devPage';
|
import DevPage from './pages/tldraw/devPage';
|
||||||
|
import ExamMarkerSpikePage from './pages/tldraw/ExamMarkerSpikePage';
|
||||||
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
||||||
import MorphicPage from './pages/morphicPage';
|
import MorphicPage from './pages/morphicPage';
|
||||||
import NotFound from './pages/user/NotFound';
|
import NotFound from './pages/user/NotFound';
|
||||||
@ -169,7 +170,6 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/classes" element={<ClassesPage />} />
|
<Route path="/classes" element={<ClassesPage />} />
|
||||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||||
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||||
<Route path="/timetable/classes/:classId" element={<ClassDetailPage />} />
|
|
||||||
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
||||||
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
||||||
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
||||||
@ -184,13 +184,10 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/search" element={<SearxngPage />} />
|
<Route path="/search" element={<SearxngPage />} />
|
||||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
|
||||||
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
|
||||||
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
|
|
||||||
<Route path="/exam-marker/:batchId/results" element={<ErrorBoundary><ExamResultsPage /></ErrorBoundary>} />
|
|
||||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||||
<Route path="/morphic" element={<MorphicPage />} />
|
<Route path="/morphic" element={<MorphicPage />} />
|
||||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||||
|
<Route path="/exam-marker-spike" element={<ExamMarkerSpikePage />} />
|
||||||
<Route path="/dev" element={<DevPage />} />
|
<Route path="/dev" element={<DevPage />} />
|
||||||
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
|
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
|
||||||
<Route path="/single-player" element={<SinglePlayerPage />} />
|
<Route path="/single-player" element={<SinglePlayerPage />} />
|
||||||
|
|||||||
@ -102,17 +102,6 @@ const PlatformAdminPage: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Destructive reset scope: exam-corpus</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
The <strong>exam-corpus</strong> reset is not limited to public papers. It wipes the entire
|
|
||||||
exam-marker subsystem: public corpus/eb_* data, cc.examboards storage, templates, template
|
|
||||||
layouts, questions, boundaries, response areas, marking batches, student submissions, and
|
|
||||||
mark entries. Use it only when you intend to rebuild all exam-marker data; it does not reset
|
|
||||||
schools, users, or timetable data.
|
|
||||||
</Typography>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@ -22,9 +22,6 @@ import {
|
|||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import ArchiveIcon from '@mui/icons-material/Archive';
|
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
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 { useAuth } from '../../contexts/AuthContext';
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
@ -37,51 +34,6 @@ const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> =
|
|||||||
archived: 'default',
|
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 ExamDashboardPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { bootstrapData } = useAuth();
|
const { bootstrapData } = useAuth();
|
||||||
@ -91,10 +43,10 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [dialog, setDialog] = useState<TemplateDialogState>(null);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [templateName, setTemplateName] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [version, setVersion] = useState('v1');
|
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
|
const [sourcePdf, setSourcePdf] = useState<File | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@ -115,99 +67,47 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load, instituteId]);
|
}, [load, instituteId]);
|
||||||
|
|
||||||
const groupedTemplates = useMemo(() => {
|
const handleOpenCreate = () => {
|
||||||
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
|
setCreateOpen(true);
|
||||||
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 = () => {
|
const handleCloseCreate = () => {
|
||||||
setTemplateName('New template');
|
if (saving) return;
|
||||||
setVersion('v1');
|
setCreateOpen(false);
|
||||||
|
setTitle('');
|
||||||
setSubject('');
|
setSubject('');
|
||||||
setDialog({ mode: 'create' });
|
setSourcePdf(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
|
const handleCreate = async () => {
|
||||||
ev.stopPropagation();
|
if (!title.trim()) return;
|
||||||
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (dialog.mode === 'create') {
|
|
||||||
const created = await examRepository.createTemplate({
|
const created = await examRepository.createTemplate({
|
||||||
title,
|
title: title.trim(),
|
||||||
subject: subject.trim() || undefined,
|
subject: subject.trim() || undefined,
|
||||||
institute_id: instituteId,
|
institute_id: instituteId,
|
||||||
|
source_pdf: sourcePdf,
|
||||||
});
|
});
|
||||||
setDialog(null);
|
setCreateOpen(false);
|
||||||
|
setTitle('');
|
||||||
|
setSubject('');
|
||||||
|
setSourcePdf(null);
|
||||||
navigate(`/exam-marker/${created.id}/setup`);
|
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) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
|
logger.error('cc-exam-marker', 'Create template failed', { message: msg });
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
|
const handleArchive = async (id: string, ev: React.MouseEvent) => {
|
||||||
ev.stopPropagation();
|
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 {
|
try {
|
||||||
await examRepository.archiveTemplate(template.id);
|
await examRepository.archiveTemplate(id);
|
||||||
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||||
@ -215,12 +115,6 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogTitle = dialog?.mode === 'edit'
|
|
||||||
? 'Rename template / edit version'
|
|
||||||
: dialog?.mode === 'duplicate'
|
|
||||||
? 'Duplicate as new version'
|
|
||||||
: 'New exam template';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -229,11 +123,11 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
<Typography variant="h3" component="h1" gutterBottom>
|
<Typography variant="h3" component="h1" gutterBottom>
|
||||||
Exam Marker
|
Exam Marker
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||||
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
Build a template for an exam paper, then run marking batches against your classes.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
||||||
New template
|
New template
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@ -253,24 +147,15 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||||
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
Create your first named template to start mapping an exam paper.
|
Create your first template to start mapping an exam paper.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
||||||
New template
|
New template
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</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}>
|
<Grid container spacing={3}>
|
||||||
{group.templates.map((t) => {
|
{templates.map((t) => (
|
||||||
const parsed = splitTemplateTitle(t.title);
|
|
||||||
return (
|
|
||||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={2}
|
elevation={2}
|
||||||
@ -286,28 +171,13 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
||||||
<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">
|
<Tooltip title="Archive">
|
||||||
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
|
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
||||||
<ArchiveIcon fontSize="small" />
|
<ArchiveIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
{t.subject && (
|
{t.subject && (
|
||||||
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
||||||
@ -318,63 +188,54 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<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" />
|
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Updated {new Date(t.updated_at).toLocaleDateString()}
|
{new Date(t.updated_at).toLocaleDateString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
<Dialog open={createOpen} onClose={handleCloseCreate} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
<DialogTitle>New exam template</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Template name"
|
label="Title"
|
||||||
value={templateName}
|
value={title}
|
||||||
onChange={(e) => setTemplateName(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
helperText="User-facing name. Several templates can share the same paper."
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Version"
|
label="Subject"
|
||||||
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}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={dialog?.mode === 'duplicate'}
|
|
||||||
/>
|
/>
|
||||||
|
<Box>
|
||||||
|
<Button variant="outlined" component="label">
|
||||||
|
{sourcePdf ? 'Replace source PDF' : 'Attach source PDF'}
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
onChange={(e) => setSourcePdf(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{sourcePdf ? sourcePdf.name : 'Optional: upload a PDF source for this template.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
<Button onClick={handleCloseCreate} disabled={saving}>Cancel</Button>
|
||||||
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||||
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
{saving ? 'Creating…' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { v5 as uuidv5 } from 'uuid';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
Container,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
|
||||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
|
||||||
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
|
||||||
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
|
||||||
|
|
||||||
const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0';
|
|
||||||
|
|
||||||
function stableMarkId(batchId: string, submissionId: string, questionId: string) {
|
|
||||||
return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExamMarkingPage: React.FC = () => {
|
|
||||||
const { batchId } = useParams<{ batchId: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [queue, setQueue] = useState<BatchQueueResponse | null>(null);
|
|
||||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [marks, setMarks] = useState<Record<string, string>>({});
|
|
||||||
const [comments, setComments] = useState<Record<string, string>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!batchId) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const nextQueue = await examRepository.getBatchQueue(batchId);
|
|
||||||
setQueue(nextQueue);
|
|
||||||
setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id));
|
|
||||||
setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [batchId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const markableQuestions = useMemo(
|
|
||||||
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
|
||||||
[template],
|
|
||||||
);
|
|
||||||
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
|
||||||
|
|
||||||
const saveSelected = async () => {
|
|
||||||
if (!batchId || !selected) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
const writes = markableQuestions
|
|
||||||
.map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] }))
|
|
||||||
.filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim());
|
|
||||||
for (const { q, raw, comment } of writes) {
|
|
||||||
const awarded = raw === undefined || raw === '' ? 0 : Number(raw);
|
|
||||||
if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) {
|
|
||||||
throw new Error(`Invalid mark for ${q.label}`);
|
|
||||||
}
|
|
||||||
await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), {
|
|
||||||
submission_id: selected.id,
|
|
||||||
question_id: q.id,
|
|
||||||
awarded_marks: awarded,
|
|
||||||
comment: comment?.trim() || undefined,
|
|
||||||
confirmed: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`);
|
|
||||||
setMarks({});
|
|
||||||
setComments({});
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseStudent = (submission: StudentSubmission) => {
|
|
||||||
setSelectedId(submission.id);
|
|
||||||
setMarks({});
|
|
||||||
setComments({});
|
|
||||||
setMessage(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !queue) {
|
|
||||||
return (
|
|
||||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
||||||
<Alert severity="error">{error}</Alert>
|
|
||||||
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
|
||||||
Back to Exam Marker
|
|
||||||
</Button>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
|
||||||
<Box>
|
|
||||||
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
|
||||||
Exam Marker
|
|
||||||
</Button>
|
|
||||||
<Typography variant="h4" component="h1" fontWeight={700}>{queue?.batch.title || 'Mark exam'}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Button variant="outlined" startIcon={<TableChartIcon />} onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
|
|
||||||
Results
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
|
||||||
{message && <Alert severity="success" onClose={() => setMessage(null)}>{message}</Alert>}
|
|
||||||
{markableQuestions.length === 0 && (
|
|
||||||
<Alert severity="warning">
|
|
||||||
This template has no markable parts yet. Add parts in template setup before entering marks.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
|
||||||
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
|
||||||
<CardContent>
|
|
||||||
<Typography variant="h6" gutterBottom>Marking queue</Typography>
|
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
|
||||||
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
|
||||||
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
|
||||||
<Chip size="small" label={`${queue?.progress.complete ?? 0} complete`} color="success" variant="outlined" />
|
|
||||||
</Stack>
|
|
||||||
<List dense disablePadding>
|
|
||||||
{(queue?.submissions ?? []).map((submission) => (
|
|
||||||
<ListItemButton
|
|
||||||
key={submission.id}
|
|
||||||
selected={submission.id === selectedId}
|
|
||||||
onClick={() => chooseStudent(submission)}
|
|
||||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={submission.student_name || submission.student_id || 'Unknown student'}
|
|
||||||
secondary={`${submission.status} · ${submission.mark_entry_count ?? 0} marks`}
|
|
||||||
/>
|
|
||||||
<Chip size="small" label={submission.status} color={submission.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
|
||||||
</ListItemButton>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card variant="outlined" sx={{ flex: 1 }}>
|
|
||||||
<CardContent>
|
|
||||||
{selected ? (
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6">{selected.student_name || selected.student_id || 'Unknown student'}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">Status: {selected.status}</Typography>
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
{markableQuestions.map((q: ExamQuestion) => (
|
|
||||||
<Stack key={q.id} direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
|
||||||
<Box sx={{ minWidth: 150 }}>
|
|
||||||
<Typography variant="body2" fontWeight={700}>{q.label}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">/ {q.max_marks} marks</Typography>
|
|
||||||
</Box>
|
|
||||||
<TextField
|
|
||||||
label="Mark"
|
|
||||||
type="number"
|
|
||||||
size="small"
|
|
||||||
inputProps={{ min: 0, max: q.max_marks, step: 0.5 }}
|
|
||||||
value={marks[q.id] ?? ''}
|
|
||||||
onChange={(e) => setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
|
||||||
sx={{ width: { xs: '100%', sm: 120 } }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Comment"
|
|
||||||
size="small"
|
|
||||||
value={comments[q.id] ?? ''}
|
|
||||||
onChange={(e) => setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
|
||||||
<Button onClick={() => navigate(`/exam-marker/${batchId}/results`)}>Skip to results</Button>
|
|
||||||
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
|
|
||||||
{saving ? 'Saving…' : 'Save marks'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No submissions in this batch.</Typography>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExamMarkingPage;
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
Container,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
|
||||||
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
|
||||||
import type { BatchResultsResponse } from '../../types/exam.types';
|
|
||||||
|
|
||||||
function formatMark(value: number | null | undefined) {
|
|
||||||
return value === null || value === undefined ? '' : String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExamResultsPage: React.FC = () => {
|
|
||||||
const { batchId } = useParams<{ batchId: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [data, setData] = useState<BatchResultsResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!batchId) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
setData(await examRepository.getBatchResults(batchId));
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [batchId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
const rows = data?.results ?? [];
|
|
||||||
const presentTotals = rows
|
|
||||||
.map((r) => r.total)
|
|
||||||
.filter((v): v is number => typeof v === 'number');
|
|
||||||
const average = presentTotals.length
|
|
||||||
? presentTotals.reduce((sum, v) => sum + v, 0) / presentTotals.length
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
total: rows.length,
|
|
||||||
absent: rows.filter((r) => r.status === 'absent' && r.total === null).length,
|
|
||||||
marked: rows.filter((r) => r.total !== null).length,
|
|
||||||
average,
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const downloadCsv = async () => {
|
|
||||||
if (!batchId) return;
|
|
||||||
setDownloading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const csv = await examRepository.getBatchCsv(batchId);
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `batch-${batchId}.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setDownloading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return (
|
|
||||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
||||||
<Alert severity="error">{error || 'Results not found'}</Alert>
|
|
||||||
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
|
||||||
Back to Exam Marker
|
|
||||||
</Button>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
|
||||||
<Box>
|
|
||||||
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
|
||||||
Exam Marker
|
|
||||||
</Button>
|
|
||||||
<Typography variant="h4" component="h1" fontWeight={700}>
|
|
||||||
{data.batch.title || 'Exam results'}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button variant="outlined" startIcon={<EditIcon />} onClick={() => navigate(`/exam-marker/${data.batch.id}/mark`)}>
|
|
||||||
Mark
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={downloading}>
|
|
||||||
{downloading ? 'Preparing…' : 'Download CSV'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
|
||||||
<Chip label={`${summary.total} students`} />
|
|
||||||
<Chip label={`${summary.marked} with marks`} color="success" variant="outlined" />
|
|
||||||
<Chip label={`${summary.absent} absent/no scan`} color="warning" variant="outlined" />
|
|
||||||
<Chip label={`Class average ${summary.average === null ? '—' : summary.average.toFixed(1)}`} color="primary" />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<TableContainer component={Paper} variant="outlined">
|
|
||||||
<Table size="small" stickyHeader>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Student</TableCell>
|
|
||||||
<TableCell>Status</TableCell>
|
|
||||||
{data.questions.map((q) => (
|
|
||||||
<TableCell key={q.id} align="right">{q.label} / {q.max_marks}</TableCell>
|
|
||||||
))}
|
|
||||||
<TableCell align="right">Total</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{data.results.map((row) => (
|
|
||||||
<TableRow key={row.submission_id} sx={row.status === 'absent' && row.total === null ? { opacity: 0.72 } : undefined}>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="body2" fontWeight={600}>{row.student_name || row.student_id || 'Unknown student'}</Typography>
|
|
||||||
{row.student_id && <Typography variant="caption" color="text.secondary">{row.student_id}</Typography>}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip size="small" label={row.status || 'unknown'} color={row.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
|
||||||
</TableCell>
|
|
||||||
{data.questions.map((q) => (
|
|
||||||
<TableCell key={q.id} align="right">{formatMark(row.marks[q.id])}</TableCell>
|
|
||||||
))}
|
|
||||||
<TableCell align="right"><strong>{formatMark(row.total)}</strong></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExamResultsPage;
|
|
||||||
@ -1,501 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
MenuItem,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
|
||||||
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
|
||||||
import type { BatchResultsResponse, ExamTemplate, MarkingBatch } from '../../types/exam.types';
|
|
||||||
|
|
||||||
interface ResultsWidgetProps {
|
|
||||||
classId: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function averageFromResults(results: BatchResultsResponse | null) {
|
|
||||||
const totals = (results?.results ?? [])
|
|
||||||
.map((row) => row.total)
|
|
||||||
.filter((value): value is number => typeof value === 'number');
|
|
||||||
if (!totals.length) return null;
|
|
||||||
return totals.reduce((sum, value) => sum + value, 0) / totals.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResultsWidget: React.FC<ResultsWidgetProps> = ({ classId, className }) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
|
||||||
const [batches, setBatches] = useState<MarkingBatch[]>([]);
|
|
||||||
const [latestResults, setLatestResults] = useState<BatchResultsResponse | null>(null);
|
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const [nextTemplates, nextBatches] = await Promise.all([
|
|
||||||
examRepository.listTemplates(),
|
|
||||||
examRepository.listBatches(),
|
|
||||||
]);
|
|
||||||
const classBatches = nextBatches
|
|
||||||
.filter((batch) => batch.class_id === classId)
|
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
||||||
setTemplates(nextTemplates);
|
|
||||||
setSelectedTemplateId((current) => current || nextTemplates[0]?.id || '');
|
|
||||||
setBatches(classBatches);
|
|
||||||
setLatestResults(classBatches[0] ? await examRepository.getBatchResults(classBatches[0].id) : null);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [classId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const latestBatch = batches[0] ?? null;
|
|
||||||
const average = useMemo(() => averageFromResults(latestResults), [latestResults]);
|
|
||||||
const absent = latestResults?.results.filter((row) => row.status === 'absent' && row.total === null).length ?? 0;
|
|
||||||
|
|
||||||
const createBatch = async () => {
|
|
||||||
if (!selectedTemplateId) return;
|
|
||||||
setCreating(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const template = templates.find((item) => item.id === selectedTemplateId);
|
|
||||||
const created = await examRepository.createBatch({
|
|
||||||
template_id: selectedTemplateId,
|
|
||||||
class_id: classId,
|
|
||||||
title: `${className || 'Class'} · ${template?.title || 'Exam'}`,
|
|
||||||
});
|
|
||||||
navigate(`/exam-marker/${created.id}/mark`);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card variant="outlined" sx={{ mb: 2 }}>
|
|
||||||
<CardContent>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<AssessmentIcon color="primary" />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6">Assessment results</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">Last exam summary for this class</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{loading && <CircularProgress size={22} />}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{error && <Alert severity="warning" onClose={() => setError(null)}>{error}</Alert>}
|
|
||||||
|
|
||||||
{latestBatch ? (
|
|
||||||
<Stack spacing={1.5}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" fontWeight={700}>{latestBatch.title || 'Exam batch'}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{new Date(latestBatch.created_at).toLocaleDateString('en-GB')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
|
||||||
<Chip size="small" color="primary" label={`Average ${average === null ? '—' : average.toFixed(1)}`} />
|
|
||||||
<Chip size="small" variant="outlined" label={`${latestResults?.results.length ?? 0} students`} />
|
|
||||||
<Chip size="small" color="warning" variant="outlined" label={`${absent} absent`} />
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button size="small" variant="contained" startIcon={<TableChartIcon />} onClick={() => navigate(`/exam-marker/${latestBatch.id}/results`)}>
|
|
||||||
View results
|
|
||||||
</Button>
|
|
||||||
<Button size="small" variant="outlined" onClick={() => navigate(`/exam-marker/${latestBatch.id}/mark`)}>
|
|
||||||
Continue marking
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
) : !loading ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No exam batches have been created for this class yet.
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
size="small"
|
|
||||||
label="Template"
|
|
||||||
value={selectedTemplateId}
|
|
||||||
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
|
||||||
disabled={!templates.length || creating}
|
|
||||||
sx={{ minWidth: 260 }}
|
|
||||||
>
|
|
||||||
{templates.map((template) => (
|
|
||||||
<MenuItem key={template.id} value={template.id}>{template.title}</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<PlayArrowIcon />}
|
|
||||||
onClick={createBatch}
|
|
||||||
disabled={!selectedTemplateId || creating}
|
|
||||||
>
|
|
||||||
{creating ? 'Creating…' : 'Create marking batch'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ResultsWidget;
|
|
||||||
@ -1,6 +1 @@
|
|||||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
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';
|
|
||||||
|
|||||||
@ -1,487 +0,0 @@
|
|||||||
|
|
||||||
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 AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
|
||||||
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 { AutoMapJobStatus, 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[] {
|
|
||||||
// S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src.
|
|
||||||
// Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights.
|
|
||||||
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 reviewSummary(template: ExamTemplateDetail | null) {
|
|
||||||
if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 }
|
|
||||||
const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])]
|
|
||||||
return rows.reduce((acc, row) => {
|
|
||||||
if (row.source === 'ai') acc.ai += 1
|
|
||||||
if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1
|
|
||||||
if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1
|
|
||||||
return acc
|
|
||||||
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
source: s.props.source ?? 'manual',
|
|
||||||
confirmed: s.props.confirmed ?? s.props.source !== 'ai',
|
|
||||||
confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null,
|
|
||||||
derivation: s.props.derivation ?? 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[]) {
|
|
||||||
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
|
||||||
if (existing.length) editor.deleteShapes(existing)
|
|
||||||
if (!models.length) return
|
|
||||||
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, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') },
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
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() } },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } {
|
|
||||||
return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string'
|
|
||||||
}
|
|
||||||
|
|
||||||
function autoMapStatusLabel(status: AutoMapJobStatus | null): string {
|
|
||||||
if (!status) return 'Auto-map running'
|
|
||||||
if (status.status === 'queued') return 'Auto-map queued'
|
|
||||||
if (status.status === 'running') return 'Auto-map running'
|
|
||||||
if (status.status === 'completed') return 'Auto-map complete'
|
|
||||||
if (status.status === 'failed') return 'Auto-map failed'
|
|
||||||
return `Auto-map ${status.status}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExamTemplateSetupInner: React.FC = () => {
|
|
||||||
const { templateId } = useParams<{ templateId: string }>()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const theme = useTheme()
|
|
||||||
const editorRef = useRef<Editor | null>(null)
|
|
||||||
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
|
|
||||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [dirty, setDirty] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [conflict, setConflict] = useState<string | null>(null)
|
|
||||||
const [activeTool, setActiveTool] = useState('select')
|
|
||||||
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
|
||||||
const [pdfError, setPdfError] = useState<string | null>(null)
|
|
||||||
const [guideOpen, setGuideOpen] = useState(false)
|
|
||||||
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
|
||||||
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
|
||||||
const autoMapPollRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
|
||||||
setTemplate(detail)
|
|
||||||
const editor = editorRef.current
|
|
||||||
if (editor) {
|
|
||||||
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
|
|
||||||
loadShapes(editor, shapes)
|
|
||||||
if (!shapes.length) seedGuide(editor)
|
|
||||||
bringDomainShapesToFront(editor)
|
|
||||||
}
|
|
||||||
setDirty(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const review = useMemo(() => reviewSummary(template), [template])
|
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const pollAutoMapStatus = useCallback(async (jobId: string) => {
|
|
||||||
if (!templateId) return
|
|
||||||
try {
|
|
||||||
const status = await examRepository.getAutoMapStatus(templateId, jobId)
|
|
||||||
setAutoMapStatus(status)
|
|
||||||
if (status.status === 'completed') {
|
|
||||||
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
|
||||||
autoMapPollRef.current = null
|
|
||||||
setAutoMapBusy(false)
|
|
||||||
const detail = status.template ?? await examRepository.getTemplate(templateId)
|
|
||||||
applyTemplateToCanvas(detail)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (status.status === 'failed') {
|
|
||||||
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
|
||||||
autoMapPollRef.current = null
|
|
||||||
setAutoMapBusy(false)
|
|
||||||
setError(status.error ?? 'Auto-map failed; existing template state was preserved.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500)
|
|
||||||
} catch (e) {
|
|
||||||
const msg = apiMessage(e).message
|
|
||||||
setAutoMapBusy(false)
|
|
||||||
setError(msg)
|
|
||||||
logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg })
|
|
||||||
}
|
|
||||||
}, [applyTemplateToCanvas, templateId])
|
|
||||||
|
|
||||||
const autoMapFromPdf = useCallback(async () => {
|
|
||||||
if (!templateId || autoMapBusy) return
|
|
||||||
let queued = false
|
|
||||||
setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null)
|
|
||||||
try {
|
|
||||||
const result = await examRepository.autoMapTemplate(templateId)
|
|
||||||
if (isAutoMapAccepted(result)) {
|
|
||||||
queued = true
|
|
||||||
setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId })
|
|
||||||
await pollAutoMapStatus(result.job_id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAutoMapStatus(null)
|
|
||||||
applyTemplateToCanvas(result)
|
|
||||||
} catch (e) {
|
|
||||||
const msg = apiMessage(e)
|
|
||||||
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
|
|
||||||
logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message })
|
|
||||||
} finally {
|
|
||||||
if (!queued) setAutoMapBusy(false)
|
|
||||||
}
|
|
||||||
}, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId])
|
|
||||||
|
|
||||||
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 layoutSummary = useMemo(() => {
|
|
||||||
const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null)
|
|
||||||
return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`)
|
|
||||||
}, [template?.layout])
|
|
||||||
|
|
||||||
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
|
||||||
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
|
||||||
color={tool.color}
|
|
||||||
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
|
|
||||||
onClick={() => {
|
|
||||||
const editor = editorRef.current
|
|
||||||
if (!editor) return
|
|
||||||
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
|
|
||||||
setActiveTool(tool.id)
|
|
||||||
}}
|
|
||||||
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
|
|
||||||
>
|
|
||||||
{tool.label}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)), [activeTool])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
|
|
||||||
{/* Top bar — single compact line */}
|
|
||||||
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
|
|
||||||
<Tooltip title="Back to exam marker">
|
|
||||||
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Divider orientation="vertical" flexItem />
|
|
||||||
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
|
||||||
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
|
|
||||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
|
||||||
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
|
|
||||||
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
|
|
||||||
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Body row */}
|
|
||||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
|
||||||
|
|
||||||
{/* Left tool sidebar */}
|
|
||||||
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
|
||||||
<Stack spacing={1}>{toolButtons}</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Canvas area */}
|
|
||||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
|
|
||||||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
|
|
||||||
<Tldraw
|
|
||||||
shapeUtils={examCanvasShapeUtils as any}
|
|
||||||
tools={examCanvasTools as any}
|
|
||||||
hideUi
|
|
||||||
inferDarkMode={theme.palette.mode === 'dark'}
|
|
||||||
autoFocus
|
|
||||||
onMount={(editor) => {
|
|
||||||
editorRef.current = editor
|
|
||||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
|
||||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
|
||||||
applyDocViewConstraints(editor, [])
|
|
||||||
editor.resetZoom()
|
|
||||||
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
|
||||||
// (Previously `else seedGuide` fired on mount while `template` was still null during
|
|
||||||
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
|
|
||||||
if (template) {
|
|
||||||
const s = shapesFromTemplate(template, pageGeometriesRef.current)
|
|
||||||
loadShapes(editor, s)
|
|
||||||
if (!s.length) seedGuide(editor)
|
|
||||||
}
|
|
||||||
bringDomainShapesToFront(editor)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Guide toggle */}
|
|
||||||
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
|
||||||
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
|
||||||
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Guide panel — collapsible */}
|
|
||||||
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
|
|
||||||
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid.
|
|
||||||
</Typography>
|
|
||||||
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
|
|
||||||
Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI.
|
|
||||||
</Alert>
|
|
||||||
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
|
||||||
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
|
||||||
const p = canvasShapePalette[kind]
|
|
||||||
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
<Divider sx={{ my: 1 }} />
|
|
||||||
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
|
|
||||||
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
|
||||||
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
|
||||||
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
{/* Conflict alert */}
|
|
||||||
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
|
||||||
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExamTemplateSetupPage: React.FC = () => (
|
|
||||||
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
|
|
||||||
<ExamTemplateSetupInner />
|
|
||||||
</ErrorBoundary>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default ExamTemplateSetupPage
|
|
||||||
@ -1,256 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react'
|
|
||||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
|
|
||||||
import type { TLHandle } from '@tldraw/tldraw'
|
|
||||||
import { PAGE_WIDTH } from '../../../utils/exam-canvas/model'
|
|
||||||
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
|
||||||
import type { ExamTemplateSource } from '../../../types/exam.types'
|
|
||||||
|
|
||||||
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
|
|
||||||
source?: 'manual' | 'ai'
|
|
||||||
confirmed?: boolean
|
|
||||||
confidence?: number | null
|
|
||||||
derivation?: string | null
|
|
||||||
reviewFlags?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type CanvasPaletteEntry = {
|
|
||||||
stroke: string
|
|
||||||
fill: string
|
|
||||||
darkStroke: string
|
|
||||||
darkFill: string
|
|
||||||
dash?: string
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
role: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
|
|
||||||
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); }
|
|
||||||
.exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); }
|
|
||||||
.exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; }
|
|
||||||
[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); }
|
|
||||||
[data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; }
|
|
||||||
`
|
|
||||||
|
|
||||||
|
|
||||||
function confidenceLabel(confidence: number | null | undefined) {
|
|
||||||
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function reviewFlags(shape: ExamCanvasTLShape): string[] {
|
|
||||||
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function provenanceTitle(shape: ExamCanvasTLShape, base: string) {
|
|
||||||
const bits = [base]
|
|
||||||
if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed')
|
|
||||||
const confidence = confidenceLabel(shape.props.confidence)
|
|
||||||
if (confidence) bits.push(`confidence ${confidence}`)
|
|
||||||
if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`)
|
|
||||||
const flags = reviewFlags(shape)
|
|
||||||
if (flags.length) bits.push(`review flags: ${flags.join(', ')}`)
|
|
||||||
return bits.join(' • ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
|
||||||
const p = canvasShapePalette.boundary
|
|
||||||
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
|
||||||
const isAi = shape.props.source === 'ai'
|
|
||||||
const confidence = confidenceLabel(shape.props.confidence)
|
|
||||||
const flags = reviewFlags(shape)
|
|
||||||
return (
|
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
|
||||||
<style>{shapeCss}</style>
|
|
||||||
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
|
||||||
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isAi ? '4 6' : p.dash} strokeLinecap="round" opacity={isAi ? 0.62 : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
|
||||||
</svg>
|
|
||||||
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
|
||||||
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
|
||||||
{shape.props.label || p.label}
|
|
||||||
</span>
|
|
||||||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
|
||||||
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
|
|
||||||
</HTMLContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderShape(shape: ExamCanvasTLShape) {
|
|
||||||
const kind = shape.props.kind
|
|
||||||
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
|
||||||
const isBoundary = kind === 'boundary'
|
|
||||||
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
|
|
||||||
if (isBoundary) return renderBoundaryLine(shape)
|
|
||||||
const isAi = shape.props.source === 'ai'
|
|
||||||
const confidence = confidenceLabel(shape.props.confidence)
|
|
||||||
const flags = reviewFlags(shape)
|
|
||||||
const title = provenanceTitle(shape, `${p.label}: ${p.role}`)
|
|
||||||
return (
|
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
|
||||||
<style>{shapeCss}</style>
|
|
||||||
<div
|
|
||||||
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
|
|
||||||
style={{
|
|
||||||
'--exam-light-stroke': p.stroke,
|
|
||||||
'--exam-light-fill': p.fill,
|
|
||||||
'--exam-dark-stroke': p.darkStroke,
|
|
||||||
'--exam-dark-fill': p.darkFill,
|
|
||||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
|
||||||
borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
|
||||||
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
|
||||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
|
||||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
|
||||||
} as React.CSSProperties}
|
|
||||||
aria-label={title}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
|
||||||
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
|
||||||
{shape.props.label || p.label}
|
|
||||||
</span>
|
|
||||||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
|
||||||
{!confidence && !isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
|
||||||
{flags.length > 0 && <span className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px', maxWidth: 'calc(100% - 16px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{flags.slice(0, 2).join(' · ')}</span>}
|
|
||||||
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
|
||||||
</div>
|
|
||||||
</HTMLContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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, source: 'manual' as const, confirmed: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
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), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) }
|
|
||||||
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
|
||||||
|
|
||||||
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
|
||||||
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 (
|
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
|
||||||
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
|
|
||||||
</HTMLContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
|
||||||
}
|
|
||||||
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
|
|
||||||
static override type = SHAPE_TYPES.boundary
|
|
||||||
static override props = sharedProps
|
|
||||||
|
|
||||||
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
|
|
||||||
override canEdit() { return false }
|
|
||||||
override canResize() { return false }
|
|
||||||
override canBind() { return false }
|
|
||||||
override hideResizeHandles() { return true }
|
|
||||||
override hideRotateHandle() { return true }
|
|
||||||
override hideSelectionBoundsBg() { return true }
|
|
||||||
|
|
||||||
private pageSpanForY(y: number) {
|
|
||||||
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
|
|
||||||
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
|
|
||||||
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
|
|
||||||
if (!best) return page
|
|
||||||
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
|
|
||||||
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
|
|
||||||
return pageDy < bestDy ? page : best
|
|
||||||
}, null)
|
|
||||||
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
|
|
||||||
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
|
|
||||||
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
|
|
||||||
}
|
|
||||||
|
|
||||||
override getGeometry(shape: ExamCanvasTLShape) {
|
|
||||||
const y = shape.props.h / 2
|
|
||||||
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
|
|
||||||
}
|
|
||||||
|
|
||||||
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
|
|
||||||
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
|
|
||||||
}
|
|
||||||
|
|
||||||
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
|
|
||||||
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
|
|
||||||
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
|
|
||||||
return this.normalize({ ...current, x: initial.x })
|
|
||||||
}
|
|
||||||
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
|
|
||||||
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
|
|
||||||
}
|
|
||||||
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
|
|
||||||
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
|
|
||||||
}
|
|
||||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { 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<ExamCanvasTLShape> { 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
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
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<PdfPageImage[]> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@ -9,7 +9,6 @@ import {
|
|||||||
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
|
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { ResultsWidget } from '../exam';
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
@ -132,20 +131,10 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
|
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
if (clsRes.id) {
|
if (clsRes.id) setCls(clsRes);
|
||||||
setCls({
|
|
||||||
...clsRes,
|
|
||||||
class_code: clsRes.class_code || clsRes.code,
|
|
||||||
year_group: clsRes.year_group || clsRes.school_year,
|
|
||||||
teachers: clsRes.teachers || [],
|
|
||||||
students: clsRes.students || [],
|
|
||||||
enrollment_requests: clsRes.enrollment_requests || [],
|
|
||||||
student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else setError(clsRes.detail || 'Class not found');
|
else setError(clsRes.detail || 'Class not found');
|
||||||
const role = bootstrapData?.active_institute?.membership_role || '';
|
const role = bootstrapData?.active_institute?.membership_role || '';
|
||||||
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
||||||
@ -185,20 +174,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddStudent = async (studentId: string) => {
|
const handleAddStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPost(`/database/timetable/classes/${classId}/students`, { student_id: studentId });
|
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
|
||||||
if (res.status === 'ok') load();
|
if (res.status === 'ok') load();
|
||||||
else setActionError(res.detail || 'Failed to add student');
|
else setActionError(res.detail || 'Failed to add student');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveStudent = async (studentId: string) => {
|
const handleRemoveStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
await apiDelete(`/database/timetable/classes/${classId}/students/${studentId}`);
|
await apiDelete(`/classes/${classId}/students/${studentId}`);
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
||||||
if (res.status === 'ok') load();
|
if (res.status === 'ok') load();
|
||||||
else setActionError(res.detail || 'Action failed');
|
else setActionError(res.detail || 'Action failed');
|
||||||
};
|
};
|
||||||
@ -265,8 +254,6 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ResultsWidget classId={cls.id} className={cls.name} />
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
<Tab label={`Students (${cls.student_count})`} />
|
<Tab label={`Students (${cls.student_count})`} />
|
||||||
|
|||||||
170
src/pages/tldraw/ExamMarkerSpikePage.tsx
Normal file
170
src/pages/tldraw/ExamMarkerSpikePage.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Tldraw,
|
||||||
|
createTLStore,
|
||||||
|
createTLSchemaFromUtils,
|
||||||
|
defaultBindingUtils,
|
||||||
|
defaultShapeUtils,
|
||||||
|
Editor,
|
||||||
|
TLAnyBindingUtilConstructor,
|
||||||
|
TLAnyShapeUtilConstructor,
|
||||||
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
|
} from '@tldraw/tldraw'
|
||||||
|
import { allBindingUtils } from '../../utils/tldraw/bindings'
|
||||||
|
import { allShapeUtils } from '../../utils/tldraw/shapes'
|
||||||
|
import { customAssets } from '../../utils/tldraw/assets'
|
||||||
|
import { getUiComponents, getUiOverrides } from '../../utils/tldraw/ui-overrides'
|
||||||
|
import { HEADER_HEIGHT } from '../Layout'
|
||||||
|
import { devTools } from '../../utils/tldraw/tools'
|
||||||
|
import {
|
||||||
|
activateExamMarkerTool,
|
||||||
|
EXAM_MARKER_BOX_TYPE,
|
||||||
|
ExamMarkerBoxShapeUtil,
|
||||||
|
ExamMarkerBoxTool,
|
||||||
|
placeExamMarkerSpike,
|
||||||
|
} from '../../utils/tldraw/exam-marker-spike'
|
||||||
|
|
||||||
|
const spikeShapeUtils = [...allShapeUtils, ExamMarkerBoxShapeUtil] as TLAnyShapeUtilConstructor[]
|
||||||
|
const spikeBindingUtils = allBindingUtils as TLAnyBindingUtilConstructor[]
|
||||||
|
const spikeTools = [...devTools, ExamMarkerBoxTool]
|
||||||
|
|
||||||
|
const spikeSchema = createTLSchemaFromUtils({
|
||||||
|
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
|
||||||
|
bindingUtils: [...defaultBindingUtils, ...spikeBindingUtils],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function ExamMarkerSpikePage() {
|
||||||
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
const [status, setStatus] = useState('Ready to insert an image and a custom exam-marker box.')
|
||||||
|
|
||||||
|
const store = useMemo(() => {
|
||||||
|
const nextStore = createTLStore({
|
||||||
|
schema: spikeSchema,
|
||||||
|
shapeUtils: spikeShapeUtils,
|
||||||
|
bindingUtils: spikeBindingUtils,
|
||||||
|
})
|
||||||
|
;(nextStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
|
||||||
|
return nextStore
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onMount = (editor: Editor) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSpike = () => {
|
||||||
|
if (!editorRef.current) return
|
||||||
|
const { imageId, boxId } = placeExamMarkerSpike(editorRef.current)
|
||||||
|
const toolId = activateExamMarkerTool(editorRef.current)
|
||||||
|
setStatus(`Inserted image ${imageId} and custom box ${boxId}; active tool is ${toolId}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusBox = () => {
|
||||||
|
if (!editorRef.current) return
|
||||||
|
const current = editorRef.current.getCurrentPageShapes().find((shape) => shape.type === EXAM_MARKER_BOX_TYPE)
|
||||||
|
if (!current) {
|
||||||
|
setStatus('No exam-marker box found yet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const bounds = editorRef.current.getShapePageBounds(current)
|
||||||
|
if (bounds) {
|
||||||
|
editorRef.current.zoomToBounds(bounds)
|
||||||
|
setStatus('Focused the camera on the exam-marker box using getShapePageBounds + zoomToBounds.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiOverrides = getUiOverrides(false)
|
||||||
|
const uiComponents = getUiComponents(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, top: HEADER_HEIGHT, background: '#f8fafc' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 10,
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 16,
|
||||||
|
background: 'rgba(15, 23, 42, 0.9)',
|
||||||
|
color: 'white',
|
||||||
|
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.22)',
|
||||||
|
maxWidth: 880,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<strong style={{ fontSize: 14 }}>Exam-marker spike</strong>
|
||||||
|
<span style={{ fontSize: 12, opacity: 0.9 }}>{status}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={insertSpike}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: 'none',
|
||||||
|
background: '#f59e0b',
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Insert image + marker box
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!editorRef.current) return
|
||||||
|
const toolId = activateExamMarkerTool(editorRef.current)
|
||||||
|
setStatus(`Activated tool ${toolId}. Drag on the canvas to create another marker box.`)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Activate box tool
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={focusBox}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Focus marker
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tldraw
|
||||||
|
store={store}
|
||||||
|
tools={spikeTools}
|
||||||
|
shapeUtils={spikeShapeUtils}
|
||||||
|
bindingUtils={spikeBindingUtils}
|
||||||
|
components={uiComponents}
|
||||||
|
overrides={uiOverrides}
|
||||||
|
assetUrls={customAssets}
|
||||||
|
autoFocus
|
||||||
|
hideUi={false}
|
||||||
|
inferDarkMode={false}
|
||||||
|
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
||||||
|
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
||||||
|
maxImageDimension={Infinity}
|
||||||
|
maxAssetSize={100 * 1024 * 1024}
|
||||||
|
onMount={onMount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,28 +8,11 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import { API_BASE } from '../../config/apiConfig';
|
import { API_BASE } from '../../config/apiConfig';
|
||||||
import { logger } from '../../debugConfig';
|
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import type {
|
import type {
|
||||||
AutoMapJobStatus,
|
|
||||||
AutoMapResponse,
|
|
||||||
BatchQueueResponse,
|
|
||||||
BatchResultsResponse,
|
|
||||||
CreateBatchPayload,
|
|
||||||
CreateTemplatePayload,
|
CreateTemplatePayload,
|
||||||
ExamBoundary,
|
|
||||||
ExamQuestion,
|
|
||||||
ExamResponseArea,
|
|
||||||
ExamTemplate,
|
ExamTemplate,
|
||||||
ExamTemplateDetail,
|
ExamTemplateDetail,
|
||||||
ExamTemplateLayout,
|
|
||||||
MarkingBatch,
|
|
||||||
MarkUpsertPayload,
|
|
||||||
Neo4jSyncResult,
|
|
||||||
PatchQuestionPayload,
|
|
||||||
SpecPoint,
|
|
||||||
TemplateReplacePayload,
|
|
||||||
UpdateTemplateMetaPayload,
|
|
||||||
} from '../../types/exam.types';
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||||
@ -42,113 +25,6 @@ async function authHeaders(): Promise<Record<string, string>> {
|
|||||||
return { Authorization: `Bearer ${session.access_token}` };
|
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<string, string>) {
|
|
||||||
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,
|
|
||||||
source: q.source ?? 'manual',
|
|
||||||
confirmed: q.confirmed ?? true,
|
|
||||||
confidence: q.confidence ?? null,
|
|
||||||
derivation: q.derivation ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, 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,
|
|
||||||
mark_subtype: r.mark_subtype ?? null,
|
|
||||||
derivation: r.derivation ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, 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,
|
|
||||||
confidence: b.confidence ?? null,
|
|
||||||
derivation: b.derivation ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
|
|
||||||
return {
|
|
||||||
id: duplicate ? newUuid() : layout.id,
|
|
||||||
page_index: layout.page_index,
|
|
||||||
role: layout.role ?? null,
|
|
||||||
margin_left: layout.margin_left ?? null,
|
|
||||||
margin_right: layout.margin_right ?? null,
|
|
||||||
margin_top: layout.margin_top ?? null,
|
|
||||||
margin_bottom: layout.margin_bottom ?? null,
|
|
||||||
margins_enabled: layout.margins_enabled ?? true,
|
|
||||||
source: layout.source ?? 'manual',
|
|
||||||
confirmed: layout.confirmed ?? true,
|
|
||||||
confidence: layout.confidence ?? null,
|
|
||||||
derivation: layout.derivation ?? null,
|
|
||||||
meta: layout.meta ?? {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function replaceTemplate(
|
|
||||||
templateId: string,
|
|
||||||
detail: ExamTemplateDetail,
|
|
||||||
meta?: UpdateTemplateMetaPayload,
|
|
||||||
duplicateIds = false,
|
|
||||||
): Promise<ExamTemplateDetail> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const idMap = new Map<string, string>();
|
|
||||||
if (duplicateIds) {
|
|
||||||
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
|
|
||||||
}
|
|
||||||
const res = await axios.put<ExamTemplateDetail>(
|
|
||||||
`${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)),
|
|
||||||
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
|
|
||||||
},
|
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const examRepository = {
|
export const examRepository = {
|
||||||
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
@ -165,139 +41,34 @@ export const examRepository = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async autoMapTemplate(templateId: string): Promise<AutoMapResponse> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.post<AutoMapResponse>(`${EXAM_BASE}/templates/${templateId}/auto-map`, {}, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAutoMapStatus(templateId: string, jobId: string): Promise<AutoMapJobStatus> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.get<AutoMapJobStatus>(`${EXAM_BASE}/templates/${templateId}/auto-map/${jobId}/status`, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
|
||||||
headers,
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
|
|
||||||
|
if (payload.source_pdf) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('title', payload.title);
|
||||||
|
if (payload.subject) form.append('subject', payload.subject);
|
||||||
|
if (payload.exam_id) form.append('exam_id', payload.exam_id);
|
||||||
|
if (payload.exam_code) form.append('exam_code', payload.exam_code);
|
||||||
|
if (payload.source_file_id) form.append('source_file_id', payload.source_file_id);
|
||||||
|
if (payload.page_count !== undefined) form.append('page_count', String(payload.page_count));
|
||||||
|
if (payload.institute_id) form.append('institute_id', payload.institute_id);
|
||||||
|
form.append('source_pdf', payload.source_pdf);
|
||||||
|
|
||||||
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, form, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
|
|
||||||
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<ExamTemplateDetail> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
async archiveTemplate(templateId: string): Promise<void> {
|
async archiveTemplate(templateId: string): Promise<void> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
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<SpecPoint[]> {
|
|
||||||
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<Neo4jSyncResult> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
|
|
||||||
headers,
|
|
||||||
params: {
|
|
||||||
include_archived: params.includeArchived ?? false,
|
|
||||||
template_id: params.templateId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res.data.batches ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async getBatchQueue(batchId: string): Promise<BatchQueueResponse> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getBatchCsv(batchId: string): Promise<string> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
|
|
||||||
const headers = await authHeaders();
|
|
||||||
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default examRepository;
|
export default examRepository;
|
||||||
|
|||||||
@ -1,17 +1,9 @@
|
|||||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
|
|
||||||
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
|
|
||||||
// resolved against the current browser origin so supabase-js receives an
|
|
||||||
// absolute URL while every request stays same-origin (no CORS) and routes
|
|
||||||
// through the app host's /__supabase nginx proxy to the backend Supabase.
|
|
||||||
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
|
|
||||||
? `${window.location.origin}${rawSupabaseUrl}`
|
|
||||||
: rawSupabaseUrl;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
throw new Error('Missing Supabase configuration');
|
throw new Error('Missing Supabase configuration');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,56 +27,12 @@ export interface CreateTemplatePayload {
|
|||||||
exam_id?: string;
|
exam_id?: string;
|
||||||
exam_code?: string;
|
exam_code?: string;
|
||||||
source_file_id?: string;
|
source_file_id?: string;
|
||||||
|
source_pdf?: File | null;
|
||||||
page_count?: number;
|
page_count?: number;
|
||||||
institute_id?: string;
|
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). */
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
export type ExamTemplateSource = 'manual' | 'ai';
|
|
||||||
|
|
||||||
export interface ExamQuestion {
|
export interface ExamQuestion {
|
||||||
id: string;
|
id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
@ -86,41 +42,22 @@ export interface ExamQuestion {
|
|||||||
max_marks: number;
|
max_marks: number;
|
||||||
answer_type: string | null;
|
answer_type: string | null;
|
||||||
mcq_options: unknown | null;
|
mcq_options: unknown | null;
|
||||||
mark_scheme: MarkScheme;
|
mark_scheme: Record<string, unknown>;
|
||||||
is_container: boolean;
|
is_container: boolean;
|
||||||
spec_ref: string | null;
|
spec_ref: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
|
||||||
page?: number | null;
|
|
||||||
source: ExamTemplateSource;
|
|
||||||
confirmed: boolean;
|
|
||||||
confidence: number | null;
|
|
||||||
derivation: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExamResponseAreaKind =
|
|
||||||
| 'response'
|
|
||||||
| 'context'
|
|
||||||
| 'question_number'
|
|
||||||
| 'mark_area'
|
|
||||||
| 'reference'
|
|
||||||
| 'furniture';
|
|
||||||
|
|
||||||
export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box';
|
|
||||||
|
|
||||||
export interface ExamResponseArea {
|
export interface ExamResponseArea {
|
||||||
id: string;
|
id: string;
|
||||||
question_id: string;
|
question_id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
page: number;
|
page: number;
|
||||||
bounds: Record<string, number>;
|
bounds: Record<string, number>;
|
||||||
kind: ExamResponseAreaKind;
|
kind: 'response' | 'context';
|
||||||
response_form: string | null;
|
response_form: string | null;
|
||||||
context_type?: string | null;
|
source: 'manual' | 'ai';
|
||||||
source: ExamTemplateSource;
|
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
confidence: number | null;
|
confidence: number | null;
|
||||||
mark_subtype?: ExamMarkSubtype | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamBoundary {
|
export interface ExamBoundary {
|
||||||
@ -131,210 +68,12 @@ export interface ExamBoundary {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds: Record<string, number> | null;
|
bounds: Record<string, number> | null;
|
||||||
source: ExamTemplateSource;
|
source: 'manual' | 'ai';
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
confidence: number | null;
|
|
||||||
derivation: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamTemplateLayout {
|
|
||||||
id: string;
|
|
||||||
template_id: string;
|
|
||||||
page_index: number;
|
|
||||||
role: string | null;
|
|
||||||
margin_left: number | null;
|
|
||||||
margin_right: number | null;
|
|
||||||
margin_top: number | null;
|
|
||||||
margin_bottom: number | null;
|
|
||||||
margins_enabled: boolean;
|
|
||||||
source: ExamTemplateSource;
|
|
||||||
confirmed: boolean;
|
|
||||||
confidence: number | null;
|
|
||||||
derivation: string | null;
|
|
||||||
meta: Record<string, unknown>;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamTemplateDetail extends ExamTemplate {
|
export interface ExamTemplateDetail extends ExamTemplate {
|
||||||
questions: ExamQuestion[];
|
questions: ExamQuestion[];
|
||||||
response_areas: ExamResponseArea[];
|
response_areas: ExamResponseArea[];
|
||||||
boundaries: ExamBoundary[];
|
boundaries: ExamBoundary[];
|
||||||
layout: ExamTemplateLayout[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
is_container?: boolean;
|
|
||||||
spec_ref?: string | null;
|
|
||||||
bounds?: Record<string, number> | null;
|
|
||||||
page?: number | null;
|
|
||||||
source?: ExamTemplateSource;
|
|
||||||
confirmed?: boolean;
|
|
||||||
confidence?: number | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
}>;
|
|
||||||
response_areas: Array<{
|
|
||||||
id?: string;
|
|
||||||
question_id: string;
|
|
||||||
page: number;
|
|
||||||
bounds: Record<string, number>;
|
|
||||||
kind: ExamResponseArea['kind'];
|
|
||||||
response_form?: string | null;
|
|
||||||
context_type?: string | null;
|
|
||||||
source?: 'manual' | 'ai';
|
|
||||||
confirmed?: boolean;
|
|
||||||
confidence?: number | null;
|
|
||||||
mark_subtype?: ExamMarkSubtype | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
}>;
|
|
||||||
boundaries: Array<{
|
|
||||||
id?: string;
|
|
||||||
question_id?: string | null;
|
|
||||||
label?: string | null;
|
|
||||||
page_index: number;
|
|
||||||
y: number;
|
|
||||||
bounds?: Record<string, number> | null;
|
|
||||||
source?: ExamTemplateSource;
|
|
||||||
confirmed?: boolean;
|
|
||||||
confidence?: number | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
}>;
|
|
||||||
layout?: Array<{
|
|
||||||
id?: string;
|
|
||||||
page_index: number;
|
|
||||||
role?: string | null;
|
|
||||||
margin_left?: number | null;
|
|
||||||
margin_right?: number | null;
|
|
||||||
margin_top?: number | null;
|
|
||||||
margin_bottom?: number | null;
|
|
||||||
margins_enabled?: boolean;
|
|
||||||
source?: ExamTemplateSource;
|
|
||||||
confirmed?: boolean;
|
|
||||||
confidence?: number | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
meta?: Record<string, unknown>;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutoMapAcceptedResponse {
|
|
||||||
status: 'accepted';
|
|
||||||
job_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutoMapJobStatus {
|
|
||||||
job_id: string;
|
|
||||||
status: 'queued' | 'running' | 'completed' | 'failed' | string;
|
|
||||||
template_id: string;
|
|
||||||
updated_at?: number;
|
|
||||||
counts?: Record<string, number>;
|
|
||||||
error?: string;
|
|
||||||
template?: ExamTemplateDetail;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AutoMapResponse = ExamTemplateDetail | AutoMapAcceptedResponse;
|
|
||||||
|
|
||||||
export interface MarkingBatch {
|
|
||||||
id: string;
|
|
||||||
template_id: string;
|
|
||||||
class_id: string | null;
|
|
||||||
institute_id: string;
|
|
||||||
teacher_id: string;
|
|
||||||
title: string | null;
|
|
||||||
status: 'open' | 'closed' | 'archived' | string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at?: string;
|
|
||||||
submission_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StudentSubmission {
|
|
||||||
id: string;
|
|
||||||
batch_id: string;
|
|
||||||
student_id: string | null;
|
|
||||||
student_name: string | null;
|
|
||||||
status: 'absent' | 'unmatched' | 'matched' | 'marking' | 'complete' | string;
|
|
||||||
storage_path?: string | null;
|
|
||||||
mark_entry_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchQueueResponse {
|
|
||||||
batch: MarkingBatch;
|
|
||||||
submissions: StudentSubmission[];
|
|
||||||
progress: {
|
|
||||||
total: number;
|
|
||||||
absent: number;
|
|
||||||
complete: number;
|
|
||||||
in_progress: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamResultRow {
|
|
||||||
submission_id: string;
|
|
||||||
student_id: string | null;
|
|
||||||
student_name: string | null;
|
|
||||||
status: string | null;
|
|
||||||
marks: Record<string, number | null | undefined>;
|
|
||||||
total: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchResultsResponse {
|
|
||||||
batch: MarkingBatch;
|
|
||||||
questions: Array<Pick<ExamQuestion, 'id' | 'label' | 'max_marks' | 'order'>>;
|
|
||||||
results: ExamResultRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateBatchPayload {
|
|
||||||
template_id: string;
|
|
||||||
class_id?: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarkUpsertPayload {
|
|
||||||
submission_id: string;
|
|
||||||
question_id: string;
|
|
||||||
awarded_marks: number;
|
|
||||||
mark_scheme_detail?: Record<string, unknown>;
|
|
||||||
annotation_shape_ids?: unknown;
|
|
||||||
comment?: string;
|
|
||||||
confirmed?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
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: [], layout: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
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.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).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, source: 'manual', confirmed: true, confidence: null, derivation: 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, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
|
||||||
],
|
|
||||||
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, derivation: 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, derivation: 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, confidence: null, derivation: null }],
|
|
||||||
})
|
|
||||||
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
|
||||||
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
|
|
||||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('carries AI provenance into canvas models, flags cheap review issues, and preserves it on save', () => {
|
|
||||||
const detail: ExamTemplateDetail = {
|
|
||||||
...template,
|
|
||||||
questions: [
|
|
||||||
{ id: '11111111-1111-4111-8111-111111111111', template_id: 'tpl-1', parent_id: null, label: 'Q?', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
|
||||||
{ id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q?', order: 0, max_marks: 0, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 100, y: 120, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
|
||||||
{ id: '66666666-6666-4666-8666-666666666666', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q1(b)', order: 1, max_marks: 1, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 150, y: 150, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.8, derivation: 'g6' },
|
|
||||||
],
|
|
||||||
response_areas: [
|
|
||||||
{ id: '33333333-3333-4333-8333-333333333333', question_id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', page: 1, bounds: { x: 120, y: 140, w: 120, h: 40 }, kind: 'response', response_form: 'lines', source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' },
|
|
||||||
],
|
|
||||||
boundaries: [
|
|
||||||
{ id: '44444444-4444-4444-8444-444444444444', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
|
||||||
{ id: '55555555-5555-4555-8555-555555555555', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? end', page_index: 0, y: 500, bounds: { x: 0, y: 500, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const shapes = shapesFromTemplate(detail)
|
|
||||||
const part = shapes.find((shape) => shape.kind === 'part')
|
|
||||||
expect(part).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
|
||||||
expect(part?.reviewFlags).toEqual(expect.arrayContaining(['unconfirmed AI', 'low confidence', 'uncertain question label', 'missing marks', 'overlapping shapes']))
|
|
||||||
|
|
||||||
const payload = serializeCanvasShapes(template, shapes)
|
|
||||||
expect(payload.questions.find((q) => q.id === '22222222-2222-4222-8222-222222222222')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
|
||||||
expect(payload.response_areas.find((r) => r.id === '33333333-3333-4333-8333-333333333333')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' })
|
|
||||||
expect(payload.boundaries.find((b) => b.id === '44444444-4444-4444-8444-444444444444')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
|
|
||||||
import type { ExamTemplateDetail, ExamTemplateSource, 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 extends Record<string, number> { 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
|
|
||||||
source?: ExamTemplateSource
|
|
||||||
confirmed?: boolean
|
|
||||||
confidence?: number | null
|
|
||||||
derivation?: string | null
|
|
||||||
reviewFlags?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
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<ExamCanvasShapeModel, 'y' | 'h'>, 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<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds & Record<string, number> {
|
|
||||||
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageGeometry(pageNumber: number, pages?: CanvasPageGeometry[]): CanvasPageGeometry {
|
|
||||||
return pages?.find((page) => page.pageNumber === pageNumber) ?? { pageNumber, x: 0, y: pageTop(pageNumber, pages), w: PAGE_WIDTH, h: PAGE_HEIGHT }
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): CanvasBounds & Record<string, number> {
|
|
||||||
const page = pageGeometry(pageForShape(shape, pages), pages)
|
|
||||||
return { x: page.x, y: shape.y, w: page.w, h: 8 }
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistedSource(shape: ExamCanvasShapeModel): ExamTemplateSource {
|
|
||||||
return shape.source ?? 'manual'
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistedConfirmed(shape: ExamCanvasShapeModel): boolean {
|
|
||||||
return shape.confirmed ?? persistedSource(shape) !== 'ai'
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistedConfidence(shape: ExamCanvasShapeModel): number | null {
|
|
||||||
return typeof shape.confidence === 'number' ? shape.confidence : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistedDerivation(shape: ExamCanvasShapeModel): string | null {
|
|
||||||
return shape.derivation ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) })
|
|
||||||
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: boundaryBounds(b, pages), source: persistedSource(b), confirmed: persistedConfirmed(b), confidence: persistedConfidence(b), derivation: persistedDerivation(b) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const partQuestionIds = new Map<string, string>()
|
|
||||||
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), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
|
|
||||||
})
|
|
||||||
|
|
||||||
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: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) })
|
|
||||||
}
|
|
||||||
|
|
||||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
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 page = pageGeometry((b.page_index ?? 0) + 1, pages)
|
|
||||||
// Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids,
|
|
||||||
// but render and save a full rendered-page-width horizontal rule.
|
|
||||||
shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id, source: b.source, confirmed: b.confirmed, confidence: b.confidence, derivation: b.derivation })
|
|
||||||
}
|
|
||||||
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 as ExamCanvasShapeModel['answerType']) ?? 'written', questionId: q.id, source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation })
|
|
||||||
}
|
|
||||||
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 as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id, source: r.source, confirmed: r.confirmed, confidence: r.confidence, derivation: r.derivation })
|
|
||||||
}
|
|
||||||
return addCheapReviewFlags(shapes, pages)
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlaps(a: ExamCanvasShapeModel, b: ExamCanvasShapeModel): boolean {
|
|
||||||
const ax2 = a.x + a.w
|
|
||||||
const ay2 = a.y + a.h
|
|
||||||
const bx2 = b.x + b.w
|
|
||||||
const by2 = b.y + b.h
|
|
||||||
return a.x < bx2 && ax2 > b.x && a.y < by2 && ay2 > b.y
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksUncertainLabel(label: string | undefined): boolean {
|
|
||||||
return !label || /\b(unknown|uncertain|maybe|todo|tbd)\b|\?/.test(label.toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCheapReviewFlags(shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
|
||||||
const markAreasByQuestion = new Set(shapes.filter((shape) => shape.kind === 'mark_area' && shape.questionId).map((shape) => shape.questionId as string))
|
|
||||||
return shapes.map((shape, index) => {
|
|
||||||
const flags: string[] = []
|
|
||||||
if (shape.source === 'ai' && shape.confirmed === false) flags.push('unconfirmed AI')
|
|
||||||
if (typeof shape.confidence === 'number' && shape.confidence < 0.7) flags.push('low confidence')
|
|
||||||
if ((shape.kind === 'part' || shape.kind === 'question_number') && looksUncertainLabel(shape.label)) flags.push('uncertain question label')
|
|
||||||
if (shape.kind === 'part' && (!shape.maxMarks || shape.maxMarks <= 0) && !markAreasByQuestion.has(shape.questionId ?? shape.id)) flags.push('missing marks')
|
|
||||||
const samePageOverlap = shapes.some((other, otherIndex) => otherIndex !== index && shape.kind !== 'boundary' && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && (shape.kind === other.kind || (!contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))))
|
|
||||||
if (samePageOverlap) flags.push('overlapping shapes')
|
|
||||||
return flags.length ? { ...shape, reviewFlags: flags } : shape
|
|
||||||
})
|
|
||||||
}
|
|
||||||
91
src/utils/tldraw/exam-marker-spike.test.ts
Normal file
91
src/utils/tldraw/exam-marker-spike.test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
createTLStore,
|
||||||
|
createTLSchemaFromUtils,
|
||||||
|
defaultBindingUtils,
|
||||||
|
defaultShapeUtils,
|
||||||
|
} from '@tldraw/tldraw'
|
||||||
|
import {
|
||||||
|
EXAM_MARKER_BOX_TYPE,
|
||||||
|
ExamMarkerBoxShapeUtil,
|
||||||
|
ExamMarkerBoxTool,
|
||||||
|
activateExamMarkerTool,
|
||||||
|
buildExamMarkerSvgDataUrl,
|
||||||
|
getExamMarkerHitTestPoints,
|
||||||
|
insertExamMarkerBox,
|
||||||
|
} from './exam-marker-spike'
|
||||||
|
|
||||||
|
const spikeShapeUtils = [ExamMarkerBoxShapeUtil]
|
||||||
|
const spikeSchema = createTLSchemaFromUtils({
|
||||||
|
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
|
||||||
|
bindingUtils: defaultBindingUtils,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof globalThis.matchMedia !== 'function') {
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: false,
|
||||||
|
media: '',
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
} as unknown as MediaQueryList))
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEditor() {
|
||||||
|
const store = createTLStore({
|
||||||
|
schema: spikeSchema,
|
||||||
|
shapeUtils: spikeShapeUtils,
|
||||||
|
bindingUtils: defaultBindingUtils,
|
||||||
|
})
|
||||||
|
;(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
|
||||||
|
|
||||||
|
return new Editor({
|
||||||
|
shapeUtils: spikeShapeUtils,
|
||||||
|
bindingUtils: defaultBindingUtils,
|
||||||
|
tools: [ExamMarkerBoxTool],
|
||||||
|
store,
|
||||||
|
getContainer: () => document.body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('exam-marker spike tldraw helpers', () => {
|
||||||
|
let editor: Editor
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
editor = makeEditor()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
editor.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a custom box shape and supports point hit-testing + page bounds', () => {
|
||||||
|
const boxId = insertExamMarkerBox(editor)
|
||||||
|
const box = editor.getShape(boxId)
|
||||||
|
|
||||||
|
expect(box?.type).toBe(EXAM_MARKER_BOX_TYPE)
|
||||||
|
expect(editor.getShapePageBounds(boxId)).toBeTruthy()
|
||||||
|
|
||||||
|
const boxPoint = getExamMarkerHitTestPoints(editor, boxId)
|
||||||
|
expect(boxPoint?.bounds).toBeTruthy()
|
||||||
|
expect(editor.getShapesAtPoint(boxPoint!.center).some((shape) => shape.id === boxId)).toBe(true)
|
||||||
|
|
||||||
|
const viewport = editor.getViewportPageBounds()
|
||||||
|
expect(viewport.w).toBeGreaterThan(0)
|
||||||
|
expect(viewport.h).toBeGreaterThan(0)
|
||||||
|
expect(() => editor.zoomToBounds(boxPoint!.bounds)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers the custom BaseBoxShapeTool and emits a locked svg asset URL', () => {
|
||||||
|
expect(activateExamMarkerTool(editor)).toBe(EXAM_MARKER_BOX_TYPE)
|
||||||
|
expect(editor.getCurrentToolId()).toBe(EXAM_MARKER_BOX_TYPE)
|
||||||
|
|
||||||
|
const svgUrl = buildExamMarkerSvgDataUrl()
|
||||||
|
expect(svgUrl.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
|
||||||
|
expect(decodeURIComponent(svgUrl.split(',')[1])).toContain('Exam scan')
|
||||||
|
})
|
||||||
|
})
|
||||||
197
src/utils/tldraw/exam-marker-spike.tsx
Normal file
197
src/utils/tldraw/exam-marker-spike.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
BaseBoxShapeTool,
|
||||||
|
BaseBoxShapeUtil,
|
||||||
|
Editor,
|
||||||
|
HTMLContainer,
|
||||||
|
T,
|
||||||
|
TLBaseBoxShape,
|
||||||
|
TLShapeId,
|
||||||
|
createShapeId,
|
||||||
|
toDomPrecision,
|
||||||
|
} from '@tldraw/tldraw'
|
||||||
|
|
||||||
|
export const EXAM_MARKER_BOX_TYPE = 'exam-marker-box' as const
|
||||||
|
|
||||||
|
export type ExamMarkerBoxShape = TLBaseBoxShape & {
|
||||||
|
type: typeof EXAM_MARKER_BOX_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExamMarkerBoxShapeUtil extends BaseBoxShapeUtil<ExamMarkerBoxShape> {
|
||||||
|
static override type = EXAM_MARKER_BOX_TYPE
|
||||||
|
static override props = {
|
||||||
|
w: T.number,
|
||||||
|
h: T.number,
|
||||||
|
}
|
||||||
|
|
||||||
|
override getDefaultProps(): ExamMarkerBoxShape['props'] {
|
||||||
|
return {
|
||||||
|
w: 320,
|
||||||
|
h: 180,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override component(shape: ExamMarkerBoxShape) {
|
||||||
|
return (
|
||||||
|
<HTMLContainer
|
||||||
|
id={shape.id}
|
||||||
|
style={{
|
||||||
|
width: toDomPrecision(shape.props.w),
|
||||||
|
height: toDomPrecision(shape.props.h),
|
||||||
|
border: '2px solid #b91c1c',
|
||||||
|
borderRadius: 14,
|
||||||
|
background: 'linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(254, 242, 242, 0.96))',
|
||||||
|
boxShadow: '0 12px 24px rgba(185, 28, 28, 0.18)',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
color: '#7f1d1d',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||||
|
Exam marker spike
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, lineHeight: 1.35 }}>
|
||||||
|
This custom box shape uses the app's tldraw schema + BaseBoxShapeTool path.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 'auto',
|
||||||
|
padding: '6px 8px',
|
||||||
|
borderRadius: 999,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
background: 'rgba(185, 28, 28, 0.12)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Drag to resize · use the spike tool to create more
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExamMarkerBoxTool extends BaseBoxShapeTool {
|
||||||
|
static override id = EXAM_MARKER_BOX_TYPE
|
||||||
|
static override initial = 'pointing'
|
||||||
|
shapeType = EXAM_MARKER_BOX_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExamMarkerSvgDataUrl(label = 'Exam scan') {
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="720" viewBox="0 0 960 720">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#ffffff"/>
|
||||||
|
<stop offset="100%" stop-color="#f4f7fb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="720" rx="32" fill="url(#g)" stroke="#cbd5e1" stroke-width="6"/>
|
||||||
|
<rect x="72" y="56" width="816" height="88" rx="18" fill="#1f2937" opacity="0.92"/>
|
||||||
|
<text x="116" y="108" fill="#fff" font-size="42" font-family="Inter, Arial, sans-serif" font-weight="700">${label}</text>
|
||||||
|
<rect x="72" y="176" width="816" height="120" rx="16" fill="#eef2ff" stroke="#c7d2fe"/>
|
||||||
|
<text x="106" y="224" fill="#1e3a8a" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Question 1</text>
|
||||||
|
<text x="106" y="266" fill="#334155" font-size="24" font-family="Inter, Arial, sans-serif">Show your working and annotate the image.</text>
|
||||||
|
<rect x="72" y="332" width="816" height="250" rx="16" fill="#fff7ed" stroke="#fed7aa"/>
|
||||||
|
<text x="106" y="384" fill="#9a3412" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Answer space</text>
|
||||||
|
<rect x="106" y="420" width="700" height="18" rx="9" fill="#fdba74" opacity="0.75"/>
|
||||||
|
<rect x="106" y="470" width="640" height="18" rx="9" fill="#fdba74" opacity="0.55"/>
|
||||||
|
<rect x="106" y="520" width="570" height="18" rx="9" fill="#fdba74" opacity="0.4"/>
|
||||||
|
<text x="106" y="644" fill="#475569" font-size="20" font-family="Inter, Arial, sans-serif">Inserted as a built-in tldraw image shape.</text>
|
||||||
|
</svg>
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewportAnchor(editor: Editor) {
|
||||||
|
const bounds = editor.getViewportPageBounds()
|
||||||
|
return {
|
||||||
|
x: bounds.x + bounds.w / 2,
|
||||||
|
y: bounds.y + bounds.h / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertExamMarkerImage(editor: Editor) {
|
||||||
|
const anchor = getViewportAnchor(editor)
|
||||||
|
const imageId = createShapeId()
|
||||||
|
|
||||||
|
editor.createShape({
|
||||||
|
id: imageId,
|
||||||
|
type: 'image',
|
||||||
|
x: anchor.x - 320,
|
||||||
|
y: anchor.y - 240,
|
||||||
|
props: {
|
||||||
|
url: buildExamMarkerSvgDataUrl(),
|
||||||
|
w: 640,
|
||||||
|
h: 480,
|
||||||
|
name: 'exam-marker-sample.svg',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return imageId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertExamMarkerBox(editor: Editor) {
|
||||||
|
const anchor = getViewportAnchor(editor)
|
||||||
|
const boxId = createShapeId()
|
||||||
|
|
||||||
|
editor.createShape<ExamMarkerBoxShape>({
|
||||||
|
id: boxId,
|
||||||
|
type: EXAM_MARKER_BOX_TYPE,
|
||||||
|
x: anchor.x + 180,
|
||||||
|
y: anchor.y - 120,
|
||||||
|
props: {
|
||||||
|
w: 320,
|
||||||
|
h: 180,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return boxId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeExamMarkerSpike(editor: Editor) {
|
||||||
|
const imageId = insertExamMarkerImage(editor)
|
||||||
|
const boxId = insertExamMarkerBox(editor)
|
||||||
|
|
||||||
|
const box = editor.getShape(boxId)
|
||||||
|
if (box) {
|
||||||
|
const bounds = editor.getShapePageBounds(box)
|
||||||
|
if (bounds) editor.zoomToBounds(bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imageId, boxId }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateExamMarkerTool(editor: Editor) {
|
||||||
|
editor.setCurrentTool(EXAM_MARKER_BOX_TYPE)
|
||||||
|
return editor.getCurrentToolId()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExamMarkerHitTestPoints(editor: Editor, shapeId: TLShapeId) {
|
||||||
|
const shape = editor.getShape(shapeId)
|
||||||
|
if (!shape) return null
|
||||||
|
|
||||||
|
const bounds = editor.getShapePageBounds(shape)
|
||||||
|
if (!bounds) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
center: {
|
||||||
|
x: bounds.x + bounds.w / 2,
|
||||||
|
y: bounds.y + bounds.h / 2,
|
||||||
|
},
|
||||||
|
bounds,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user