Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d47e0205c9 | ||
|
|
11d19778d6 | ||
|
|
a80bdea140 | ||
|
|
905626f494 | ||
|
|
b52f6c54ba | ||
|
|
e6a2480818 | ||
|
|
2b33aa80eb | ||
|
|
7a1dd4dccc | ||
|
|
4001772d5e | ||
|
|
f72ea7189c | ||
|
|
7bd66fbaf0 | ||
|
|
9c3a5f97cc | ||
|
|
2ccfb9ccd6 | ||
|
|
ef13a124dd | ||
|
|
ffa0ad85ac | ||
|
|
92f9dfef82 | ||
|
|
824031f2c0 | ||
|
|
7db852aaff | ||
|
|
afc0371dd9 | ||
|
|
7326b9f3be | ||
|
|
cd8ac38d39 | ||
|
|
469bcc0517 | ||
|
|
e899af303d | ||
|
|
7a01b3e8f6 | ||
|
|
bff91a4b17 | ||
|
|
66f35b8ae4 | ||
|
|
fe5dbe7fa8 | ||
|
|
15a519748d | ||
|
|
3389fdcb5b | ||
|
|
ab35193be1 | ||
|
|
b3f71c5749 | ||
|
|
b396af96b9 | ||
|
|
3eac792ced | ||
|
|
8e8a345e61 | ||
|
|
2de3e29179 | ||
|
|
29390d30ca | ||
|
|
aa2f35e467 | ||
|
|
61a189a7a2 | ||
|
|
ab6f0b09d7 | ||
|
|
16ae3aa089 | ||
|
|
496ec2cbf9 | ||
|
|
dea3275f23 | ||
|
|
f067db3eb8 | ||
|
|
1ab8aab43c | ||
|
|
fdbc19cf0d |
+61
-7
@@ -7,18 +7,45 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Vite bakes VITE_* values at build time, so compose must choose the env file
|
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
|
||||||
# during image build, not only at container runtime.
|
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
|
||||||
ARG ENV_FILE=.env
|
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
|
||||||
COPY ${ENV_FILE} .env
|
ARG VITE_API_BASE
|
||||||
|
ARG VITE_API_URL
|
||||||
# Run build with production mode
|
ARG VITE_APP_NAME
|
||||||
RUN npm run build -- --mode production
|
ARG VITE_APP_HMR_URL
|
||||||
|
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; \
|
||||||
@@ -28,6 +55,33 @@ 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; \
|
||||||
|
|||||||
+22
-1
@@ -16,7 +16,28 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
ENV_FILE: .env.dev
|
# app-dev is served by nginx on the app host; browser API calls must stay
|
||||||
|
# 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,7 +31,14 @@ 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', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
|
vi.mock('./pages/exam', () => ({
|
||||||
|
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> }));
|
||||||
@@ -122,3 +129,22 @@ 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: '[email protected]', user_type: 'email_teacher' },
|
||||||
|
user_role: 'email_teacher',
|
||||||
|
accessToken: 'token',
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderAt('/exam-marker/template-123/marks');
|
||||||
|
|
||||||
|
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+6
-1
@@ -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, ExamTemplateSetupPage } from './pages/exam';
|
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage } 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';
|
||||||
@@ -169,6 +169,7 @@ 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 />} />
|
||||||
@@ -183,7 +184,11 @@ 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/bank" element={<ErrorBoundary><QuestionBankPage /></ErrorBoundary>} />
|
||||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></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 />} />
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ 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, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -22,6 +22,10 @@ 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 PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||||
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
@@ -34,6 +38,51 @@ 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();
|
||||||
@@ -43,8 +92,9 @@ 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 [createOpen, setCreateOpen] = useState(false);
|
const [dialog, setDialog] = useState<TemplateDialogState>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [templateName, setTemplateName] = useState('');
|
||||||
|
const [version, setVersion] = useState('v1');
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -66,33 +116,99 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load, instituteId]);
|
}, [load, instituteId]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const groupedTemplates = useMemo(() => {
|
||||||
if (!title.trim()) return;
|
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
|
||||||
|
templates.forEach((template) => {
|
||||||
|
const key = paperKey(template);
|
||||||
|
const existing = groups.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.templates.push(template);
|
||||||
|
} else {
|
||||||
|
groups.set(key, { label: paperLabel(template), templates: [template] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
|
||||||
|
}, [templates]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setTemplateName('New template');
|
||||||
|
setVersion('v1');
|
||||||
|
setSubject('');
|
||||||
|
setDialog({ mode: 'create' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const parsed = splitTemplateTitle(template.title);
|
||||||
|
setTemplateName(parsed.name);
|
||||||
|
setVersion(parsed.version);
|
||||||
|
setSubject(template.subject ?? '');
|
||||||
|
setDialog({ mode: 'edit', template });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const parsed = splitTemplateTitle(template.title);
|
||||||
|
setTemplateName(parsed.name);
|
||||||
|
setVersion(nextVersionLabel(parsed.version));
|
||||||
|
setSubject(template.subject ?? '');
|
||||||
|
setDialog({ mode: 'duplicate', template });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
if (!saving) setDialog(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDialog = async () => {
|
||||||
|
if (!dialog || !templateName.trim()) return;
|
||||||
|
const title = composeTemplateTitle(templateName, version);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const created = await examRepository.createTemplate({
|
if (dialog.mode === 'create') {
|
||||||
title: title.trim(),
|
const created = await examRepository.createTemplate({
|
||||||
subject: subject.trim() || undefined,
|
title,
|
||||||
institute_id: instituteId,
|
subject: subject.trim() || undefined,
|
||||||
|
institute_id: instituteId,
|
||||||
|
});
|
||||||
|
setDialog(null);
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dialog.template) return;
|
||||||
|
|
||||||
|
if (dialog.mode === 'duplicate') {
|
||||||
|
const created = await examRepository.duplicateTemplate(dialog.template.id, title);
|
||||||
|
setTemplates((prev) => [created, ...prev]);
|
||||||
|
setDialog(null);
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
|
||||||
|
title,
|
||||||
|
subject: subject.trim() || null,
|
||||||
});
|
});
|
||||||
setCreateOpen(false);
|
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
setTitle('');
|
setDialog(null);
|
||||||
setSubject('');
|
|
||||||
navigate(`/exam-marker/${created.id}/setup`);
|
|
||||||
} 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', 'Create template failed', { message: msg });
|
logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async (id: string, ev: React.MouseEvent) => {
|
const handleArchive = async (template: ExamTemplate, 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(id);
|
await examRepository.archiveTemplate(template.id);
|
||||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
setTemplates((prev) => prev.filter((t) => t.id !== template.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 });
|
||||||
@@ -100,6 +216,12 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dialogTitle = dialog?.mode === 'edit'
|
||||||
|
? 'Rename template / edit version'
|
||||||
|
: dialog?.mode === 'duplicate'
|
||||||
|
? 'Duplicate as new version'
|
||||||
|
: 'New exam template';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@@ -108,13 +230,18 @@ 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: 560 }}>
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
|
||||||
Build a template for an exam paper, then run marking batches against your classes.
|
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
<Stack direction="row" spacing={1}>
|
||||||
New template
|
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
|
||||||
</Button>
|
Question bank
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -132,73 +259,128 @@ 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 template to start mapping an exam paper.
|
Create your first named template to start mapping an exam paper.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
New template
|
New template
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<Grid container spacing={3}>
|
<Stack spacing={3}>
|
||||||
{templates.map((t) => (
|
{groupedTemplates.map((group) => (
|
||||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
<Box key={group.key}>
|
||||||
<Paper
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
elevation={2}
|
<Typography variant="h6">{group.label}</Typography>
|
||||||
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
|
||||||
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
</Stack>
|
||||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
<Grid container spacing={3}>
|
||||||
>
|
{group.templates.map((t) => {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
const parsed = splitTemplateTitle(t.title);
|
||||||
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
return (
|
||||||
<Tooltip title="Archive">
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
<Paper
|
||||||
<ArchiveIcon fontSize="small" />
|
elevation={2}
|
||||||
</IconButton>
|
sx={{
|
||||||
</Tooltip>
|
p: 3,
|
||||||
</Box>
|
height: '100%',
|
||||||
{t.subject && (
|
cursor: 'pointer',
|
||||||
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
display: 'flex',
|
||||||
)}
|
flexDirection: 'column',
|
||||||
{t.exam_code && (
|
gap: 1,
|
||||||
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
transition: 'box-shadow 120ms',
|
||||||
)}
|
'&:hover': { boxShadow: 6 },
|
||||||
<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" />
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||||
<Typography variant="caption" color="text.secondary">
|
>
|
||||||
{new Date(t.updated_at).toLocaleDateString()}
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||||
</Typography>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
</Box>
|
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
|
||||||
</Paper>
|
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
|
||||||
</Grid>
|
</Box>
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
<Tooltip title="Rename / edit version">
|
||||||
|
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Duplicate as new version">
|
||||||
|
<IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Archive">
|
||||||
|
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
|
||||||
|
<ArchiveIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
{t.subject && (
|
||||||
|
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
||||||
|
)}
|
||||||
|
{t.exam_code && (
|
||||||
|
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Updated {new Date(t.updated_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
||||||
|
startIcon={<GradingIcon fontSize="small" />}
|
||||||
|
>
|
||||||
|
Edit marks
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>New exam template</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Title"
|
label="Template name"
|
||||||
value={title}
|
value={templateName}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
|
helperText="User-facing name. Several templates can share the same paper."
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Subject"
|
label="Version"
|
||||||
|
value={version}
|
||||||
|
onChange={(e) => setVersion(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
helperText="Stored in the template title until the API grows a dedicated version column."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Paper / subject label"
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
disabled={dialog?.mode === 'duplicate'}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
||||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
||||||
{saving ? 'Creating…' : 'Create'}
|
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, 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 UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
|
||||||
|
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 [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | 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 onScanFiles = useCallback(async (files: FileList | null) => {
|
||||||
|
if (!batchId || !files || files.length === 0) return;
|
||||||
|
setUploading(true); setError(null); setMessage(null);
|
||||||
|
let ok = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
// 'ordered' assigns each scan to the next not-yet-submitted roster slot; unmatched scans
|
||||||
|
// become an unmatched submission the teacher can reconcile.
|
||||||
|
await examRepository.uploadScan(batchId, file, { matchingMethod: 'ordered' });
|
||||||
|
ok += 1;
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
await load();
|
||||||
|
if (ok) setMessage(`Uploaded ${ok} scan${ok === 1 ? '' : 's'}${failures.length ? `, ${failures.length} failed` : ''}`);
|
||||||
|
if (failures.length) setError(failures.join(' · '));
|
||||||
|
}, [batchId, load]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
|
||||||
|
<Typography variant="h6">Marking queue</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFileIcon fontSize="small" />}
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : 'Upload scans'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => { void onScanFiles(e.target.files); }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
|
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
|
||||||
|
</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;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert, Box, Button, Card, CardActionArea, CardContent, Chip, CircularProgress, Container,
|
||||||
|
Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { BankQuestion, BankResponse } from '../../types/exam.types';
|
||||||
|
import { cropQuestion } from './bankCrops';
|
||||||
|
|
||||||
|
const QuestionCrop: React.FC<{ q: BankQuestion }> = ({ q }) => {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
cropQuestion({ templateId: q.template_id, page: q.page, bounds: q.bounds })
|
||||||
|
.then((d) => { if (alive) { setSrc(d); setDone(true); } })
|
||||||
|
.catch(() => { if (alive) setDone(true); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [q.template_id, q.page, q.bounds]);
|
||||||
|
if (src) {
|
||||||
|
return <Box component="img" src={src} alt="" loading="lazy" sx={{ width: '100%', maxHeight: 150, objectFit: 'cover', objectPosition: 'top', display: 'block', bgcolor: '#fff', borderBottom: 1, borderColor: 'divider' }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: 1, borderColor: 'divider', color: 'text.disabled', fontSize: 12 }}>
|
||||||
|
{done ? 'no preview' : <CircularProgress size={16} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const QuestionBankPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [bank, setBank] = useState<BankResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [specRef, setSpecRef] = useState<string | null>(null);
|
||||||
|
const [subject, setSubject] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
try {
|
||||||
|
setBank(await examRepository.getBank({ specRef: specRef ?? undefined, subject: subject ?? undefined }));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [specRef, subject]);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const toggle = (id: string) => setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedList = useMemo(() => Array.from(selected), [selected]);
|
||||||
|
const selectedQuestions = useMemo(
|
||||||
|
() => (bank?.questions ?? []).filter((q) => selected.has(q.id)),
|
||||||
|
[bank, selected],
|
||||||
|
);
|
||||||
|
const totalMarks = selectedQuestions.reduce((s, q) => s + (q.max_marks ?? 0), 0);
|
||||||
|
|
||||||
|
// facet lists (sorted) with counts
|
||||||
|
const specFacets = Object.entries(bank?.facets.spec_ref ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||||||
|
const subjectFacets = Object.entries(bank?.facets.subject ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setCreating(true); setError(null);
|
||||||
|
try {
|
||||||
|
const subj = subject ?? selectedQuestions[0]?.paper.subject ?? undefined;
|
||||||
|
const res = await examRepository.createCustomPaper({
|
||||||
|
title: title.trim() || `Custom paper — ${new Date().toISOString().slice(0, 10)}`,
|
||||||
|
subject: subj ?? undefined,
|
||||||
|
question_ids: selectedList,
|
||||||
|
});
|
||||||
|
navigate(`/exam-marker/${res.id}/setup`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setCreating(false);
|
||||||
|
setDialogOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" component="h1" fontWeight={700}>Question bank</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Plan by spec point, then assemble a custom paper from questions across your papers.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PlaylistAddCheckIcon />}
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
>
|
||||||
|
Create paper ({selected.size}{totalMarks ? ` · ${totalMarks}m` : ''})
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Spec point:</Typography>
|
||||||
|
<Chip size="small" label="all" color={specRef ? 'default' : 'primary'} onClick={() => setSpecRef(null)} variant={specRef ? 'outlined' : 'filled'} />
|
||||||
|
{specFacets.map(([ref, count]) => (
|
||||||
|
<Chip key={ref} size="small" label={`${ref} · ${count}`} color={specRef === ref ? 'primary' : 'default'} variant={specRef === ref ? 'filled' : 'outlined'} onClick={() => setSpecRef(ref)} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{subjectFacets.length > 1 && (
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Subject:</Typography>
|
||||||
|
<Chip size="small" label="all" color={subject ? 'default' : 'secondary'} onClick={() => setSubject(null)} variant={subject ? 'outlined' : 'filled'} />
|
||||||
|
{subjectFacets.map(([s, count]) => (
|
||||||
|
<Chip key={s} size="small" label={`${s} · ${count}`} color={subject === s ? 'secondary' : 'default'} variant={subject === s ? 'filled' : 'outlined'} onClick={() => setSubject(s)} sx={{ textTransform: 'capitalize' }} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||||
|
) : (bank?.questions.length ?? 0) === 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
No questions match yet. Questions appear here once your templates have parts with spec points
|
||||||
|
(set them in the mark-scheme editor). Auto-map + confirm on a paper to populate it.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
{bank?.n} question{bank?.n === 1 ? '' : 's'} · click to add to your paper
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gap: 1.5 }}>
|
||||||
|
{(bank?.questions ?? []).map((q: BankQuestion) => {
|
||||||
|
const on = selected.has(q.id);
|
||||||
|
return (
|
||||||
|
<Card key={q.id} variant="outlined" sx={{ borderColor: on ? 'primary.main' : 'divider', borderWidth: on ? 2 : 1 }}>
|
||||||
|
<CardActionArea onClick={() => toggle(q.id)}>
|
||||||
|
<QuestionCrop q={q} />
|
||||||
|
<CardContent sx={{ pb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={700}>Q{q.label ?? '?'}</Typography>
|
||||||
|
{on ? <CheckCircleIcon color="primary" fontSize="small" /> : <RadioButtonUncheckedIcon color="disabled" fontSize="small" />}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 0.75 }}>
|
||||||
|
<Chip size="small" label={`${q.max_marks ?? 0} marks`} />
|
||||||
|
{q.answer_type && <Chip size="small" variant="outlined" label={q.answer_type} />}
|
||||||
|
{q.spec_ref && <Chip size="small" color="info" label={q.spec_ref} />}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', textTransform: 'capitalize' }}>
|
||||||
|
{[q.paper.subject, q.paper.title].filter(Boolean).join(' · ') || 'Untitled paper'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={() => !creating && setDialogOpen(false)} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Create custom paper</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{selected.size} question{selected.size === 1 ? '' : 's'} · {totalMarks} marks. This creates a new
|
||||||
|
template you can open in setup and use for marking.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
label="Paper title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={`Custom paper — ${new Date().toISOString().slice(0, 10)}`}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDialogOpen(false)} disabled={creating}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={create} disabled={creating} startIcon={creating ? <CircularProgress size={16} color="inherit" /> : undefined}>
|
||||||
|
{creating ? 'Creating…' : 'Create & open'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuestionBankPage;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Client-side crop thumbnails for the question bank. A bank question carries `bounds` (x,y,w,h) in the
|
||||||
|
// 780px stacked-page canvas space and a `page` number; the source PDF renders at that same 780px width
|
||||||
|
// (pdfLoader), so a thumbnail is just the page image cropped to the bounds. Renders each template's PDF
|
||||||
|
// once (cached) and reuses it for all of that template's questions. No API/infra change.
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import { loadPdfPageImages, PdfPageImage } from './setup/pdfLoader';
|
||||||
|
|
||||||
|
const renderCache = new Map<string, Promise<PdfPageImage[]>>();
|
||||||
|
const imageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||||
|
|
||||||
|
function templateRender(templateId: string): Promise<PdfPageImage[]> {
|
||||||
|
let p = renderCache.get(templateId);
|
||||||
|
if (!p) {
|
||||||
|
p = examRepository.getTemplateSourcePdf(templateId).then((bytes) => loadPdfPageImages(bytes));
|
||||||
|
p.catch(() => renderCache.delete(templateId)); // let a failed render be retried
|
||||||
|
renderCache.set(templateId, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
let p = imageCache.get(src);
|
||||||
|
if (!p) {
|
||||||
|
p = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('image decode failed'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
imageCache.set(src, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CropInput {
|
||||||
|
templateId: string;
|
||||||
|
page: number | null;
|
||||||
|
bounds: Record<string, number> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a PNG data-URL cropped to the question, or null if it can't be produced (no bounds/page/PDF).
|
||||||
|
export async function cropQuestion(input: CropInput, maxWidth = 320): Promise<string | null> {
|
||||||
|
const { templateId, page, bounds } = input;
|
||||||
|
if (!bounds || !page) return null;
|
||||||
|
const pages = await templateRender(templateId);
|
||||||
|
const pageImg = pages.find((pg) => pg.pageNumber === page);
|
||||||
|
if (!pageImg) return null;
|
||||||
|
|
||||||
|
// stacked y-offset of this page (bounds.y is absolute across the vertically-stacked pages)
|
||||||
|
let yTop = 0;
|
||||||
|
for (const pg of pages) { if (pg.pageNumber === page) break; yTop += pg.height; }
|
||||||
|
|
||||||
|
const sx = Math.max(0, Math.round(bounds.x ?? 0));
|
||||||
|
const sy = Math.max(0, Math.round((bounds.y ?? 0) - yTop));
|
||||||
|
const sw = Math.min(Math.round(bounds.w ?? pageImg.width), pageImg.width - sx);
|
||||||
|
const sh = Math.min(Math.round(bounds.h ?? 80), pageImg.height - sy);
|
||||||
|
if (sw <= 1 || sh <= 1) return null;
|
||||||
|
|
||||||
|
const img = await loadImage(pageImg.src);
|
||||||
|
const scale = Math.min(1, maxWidth / sw);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(sw * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(sh * scale));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
|
export { default as QuestionBankPage } from './QuestionBankPage';
|
||||||
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
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,35 +1,69 @@
|
|||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
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 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 SaveIcon from '@mui/icons-material/Save'
|
||||||
import MouseIcon from '@mui/icons-material/Mouse'
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
import CheckIcon from '@mui/icons-material/Check'
|
||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import DoneAllIcon from '@mui/icons-material/DoneAll'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw'
|
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||||
import { logger } from '../../../debugConfig'
|
import { logger } from '../../../debugConfig'
|
||||||
import { examRepository } from '../../../services/exam/examRepository'
|
import { examRepository } from '../../../services/exam/examRepository'
|
||||||
import type { ExamTemplateDetail } from '../../../types/exam.types'
|
import type { AutoMapJobStatus, ExamTemplateDetail } from '../../../types/exam.types'
|
||||||
import { ExamCanvasShapeModel, PAGE_WIDTH, type PdfPageLayout, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||||
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, PDF_PAGE_SHAPE_TYPE, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes'
|
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||||
import { renderPdfBackdrop, replacePdfPageShapes } from './pdfLoader'
|
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete user regions. The PDF backdrop is locked.', color: 'default' as const },
|
{ 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', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' 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', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' 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', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' 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', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' 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', tip: 'Box the printed question number.', color: 'success' 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', tip: 'Box printed marks such as [2].', 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', tip: 'Box student resources/reference material.', color: 'info' 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', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' 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 } {
|
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||||
if (axios.isAxiosError(err)) {
|
if (axios.isAxiosError(err)) {
|
||||||
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||||
@@ -39,16 +73,58 @@ function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
|||||||
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live review counts read straight off the canvas shapes, so accept/reject update them immediately
|
||||||
|
// (the template-derived reviewSummary only reflects the last *saved* state).
|
||||||
|
function reviewFromShapes(editor: Editor | null) {
|
||||||
|
const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 }
|
||||||
|
if (!editor) return acc
|
||||||
|
for (const shape of editor.getCurrentPageShapes()) {
|
||||||
|
if (!shapeTypeToKind(shape.type)) continue
|
||||||
|
const p = (shape as ExamCanvasTLShape).props
|
||||||
|
if (p.source === 'ai') acc.ai += 1
|
||||||
|
if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1
|
||||||
|
if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
function stripShapePrefix(id: string) {
|
function stripShapePrefix(id: string) {
|
||||||
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
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 {
|
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||||
const kind = shapeTypeToKind(shape.type)
|
const kind = shapeTypeToKind(shape.type)
|
||||||
if (!kind) return null
|
if (!kind) return null
|
||||||
const s = shape as ExamCanvasTLShape
|
const s = shape as ExamCanvasTLShape
|
||||||
return {
|
return {
|
||||||
id: s.props.domainId || stripShapePrefix(s.id),
|
id: domainIdForShape(s),
|
||||||
kind,
|
kind,
|
||||||
x: Number(s.x ?? 0),
|
x: Number(s.x ?? 0),
|
||||||
y: Number(s.y ?? 0),
|
y: Number(s.y ?? 0),
|
||||||
@@ -59,9 +135,18 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
|||||||
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
||||||
contextType: s.props.contextType,
|
contextType: s.props.contextType,
|
||||||
questionId: s.props.questionId ?? null,
|
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[]) {
|
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||||
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||||
if (existing.length) editor.deleteShapes(existing)
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
@@ -71,77 +156,161 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
|||||||
type: SHAPE_TYPES[m.kind],
|
type: SHAPE_TYPES[m.kind],
|
||||||
x: m.x,
|
x: m.x,
|
||||||
y: m.y,
|
y: m.y,
|
||||||
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, domainId: m.id, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId },
|
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 newDomainId() {
|
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
||||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
|
||||||
return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
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) {
|
function seedGuide(editor: Editor) {
|
||||||
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
||||||
if (current.length) return
|
if (current.length) return
|
||||||
editor.createShapes([
|
editor.createShapes([
|
||||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 start' } },
|
{ 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: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 end' } },
|
{ 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', domainId: newDomainId(), label: 'Q1(a)', maxMarks: 3 } },
|
{ 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', domainId: newDomainId(), label: 'Response', responseForm: 'lines' } },
|
{ 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 ExamTemplateSetupInner: React.FC = () => {
|
||||||
const { templateId } = useParams<{ templateId: string }>()
|
const { templateId } = useParams<{ templateId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const editorRef = useRef<Editor | null>(null)
|
const editorRef = useRef<Editor | null>(null)
|
||||||
const pageLayoutsRef = useRef<PdfPageLayout[]>([])
|
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
|
||||||
const hydratingRef = useRef(false)
|
|
||||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [pdfLoading, setPdfLoading] = useState(false)
|
|
||||||
const [pdfStatus, setPdfStatus] = useState<string>('PDF not loaded')
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [conflict, setConflict] = useState<string | null>(null)
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
const [activeTool, setActiveTool] = useState('select')
|
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 [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||||
|
const autoMapPollRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const loadPdf = useCallback(async (id: string, editor: Editor) => {
|
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
|
||||||
setPdfLoading(true)
|
|
||||||
setPdfStatus('Loading PDF…')
|
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
||||||
try {
|
setTemplate(detail)
|
||||||
const result = await renderPdfBackdrop(id)
|
const editor = editorRef.current
|
||||||
hydratingRef.current = true
|
if (editor) {
|
||||||
replacePdfPageShapes(editor, result.pages)
|
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
|
||||||
pageLayoutsRef.current = result.pageLayouts
|
loadShapes(editor, shapes)
|
||||||
setPdfStatus(`${result.pages.length} PDF page${result.pages.length === 1 ? '' : 's'} loaded`)
|
if (!shapes.length) seedGuide(editor)
|
||||||
editor.zoomToFit()
|
bringDomainShapesToFront(editor)
|
||||||
} catch (e) {
|
|
||||||
pageLayoutsRef.current = []
|
|
||||||
const msg = apiMessage(e).message
|
|
||||||
setPdfStatus(`PDF unavailable: ${msg}`)
|
|
||||||
logger.warn('cc-exam-marker', 'Template setup PDF backdrop failed', { templateId: id, message: msg })
|
|
||||||
} finally {
|
|
||||||
hydratingRef.current = false
|
|
||||||
setPdfLoading(false)
|
|
||||||
setDirty(false)
|
|
||||||
}
|
}
|
||||||
|
setDirty(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const acceptShapes = useCallback((shapes: TLShape[]) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const updates = shapes
|
||||||
|
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
|
||||||
|
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
|
||||||
|
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
|
||||||
|
}, [refreshReview])
|
||||||
|
|
||||||
|
const selectedDomainShapes = useCallback((): TLShape[] => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return []
|
||||||
|
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
|
||||||
|
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
|
||||||
|
const rejectSelected = useCallback(() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const ids = selectedDomainShapes().map((s) => s.id)
|
||||||
|
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
|
||||||
|
}, [refreshReview, selectedDomainShapes])
|
||||||
|
|
||||||
|
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
|
||||||
|
// saved-template summary only for the brief moment before shapes have mounted).
|
||||||
|
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
setLoading(true); setError(null); setConflict(null)
|
setLoading(true); setError(null); setConflict(null)
|
||||||
try {
|
try {
|
||||||
const detail = await examRepository.getTemplate(templateId)
|
const detail = await examRepository.getTemplate(templateId)
|
||||||
setTemplate(detail)
|
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
|
const editor = editorRef.current
|
||||||
if (editor) {
|
if (editor) {
|
||||||
hydratingRef.current = true
|
syncPdfPages(editor, pages)
|
||||||
loadShapes(editor, shapesFromTemplate(detail))
|
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||||
hydratingRef.current = false
|
bringDomainShapesToFront(editor)
|
||||||
await loadPdf(templateId, editor)
|
applyDocViewConstraints(editor, pages)
|
||||||
|
editor.resetZoom()
|
||||||
}
|
}
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -151,22 +320,77 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [loadPdf, templateId])
|
}, [templateId])
|
||||||
|
|
||||||
useEffect(() => { void load() }, [load])
|
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 save = useCallback(async () => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (!editor || !templateId || !template) return
|
if (!editor || !templateId || !template) return
|
||||||
setSaving(true); setError(null); setConflict(null)
|
setSaving(true); setError(null); setConflict(null)
|
||||||
try {
|
try {
|
||||||
const shapes = editor.getCurrentPageShapes().filter((s) => s.type !== PDF_PAGE_SHAPE_TYPE).map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
ensureDomainIds(editor)
|
||||||
const payload = serializeCanvasShapes(template, shapes, pageLayoutsRef.current)
|
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||||
|
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
|
||||||
const saved = await examRepository.replaceTemplate(templateId, payload)
|
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||||
setTemplate(saved)
|
setTemplate(saved)
|
||||||
hydratingRef.current = true
|
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
|
||||||
loadShapes(editor, shapesFromTemplate(saved))
|
|
||||||
hydratingRef.current = false
|
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = apiMessage(e)
|
const msg = apiMessage(e)
|
||||||
@@ -177,13 +401,18 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [template, templateId])
|
}, [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) => (
|
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||||
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
||||||
color={tool.color}
|
color={tool.color}
|
||||||
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : undefined}
|
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -198,57 +427,131 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
)), [activeTool])
|
)), [activeTool])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default' }}>
|
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
|
||||||
<Tldraw
|
{/* Top bar — single compact line */}
|
||||||
shapeUtils={examCanvasShapeUtils as any}
|
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
|
||||||
bindingUtils={defaultBindingUtils}
|
<Tooltip title="Back to exam marker">
|
||||||
tools={examCanvasTools as any}
|
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
|
||||||
hideUi
|
</Tooltip>
|
||||||
inferDarkMode={theme.palette.mode === 'dark'}
|
<Divider orientation="vertical" flexItem />
|
||||||
autoFocus
|
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
||||||
onMount={(editor) => {
|
<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'} />
|
||||||
editorRef.current = editor
|
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||||
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
|
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
|
||||||
editor.store.listen((entry) => {
|
<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>
|
||||||
const changedPdfOnly = Object.values(entry.changes.added).every((record: any) => record?.type === PDF_PAGE_SHAPE_TYPE)
|
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
if (!hydratingRef.current && !changedPdfOnly) setDirty(true)
|
</Paper>
|
||||||
}, { scope: 'document' })
|
|
||||||
if (template) {
|
{/* Body row */}
|
||||||
hydratingRef.current = true
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
loadShapes(editor, shapesFromTemplate(template))
|
|
||||||
hydratingRef.current = false
|
{/* Left tool sidebar */}
|
||||||
if (templateId) void loadPdf(templateId, editor)
|
<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' }}>
|
||||||
} else seedGuide(editor)
|
<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); refreshReview() }, { scope: 'document' })
|
||||||
|
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
|
||||||
|
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
|
||||||
|
// update below can't recurse.
|
||||||
|
editor.store.listen((entry) => {
|
||||||
|
try {
|
||||||
|
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
|
||||||
|
for (const rec of Object.values(entry.changes.updated)) {
|
||||||
|
const [from, to] = rec as [any, any]
|
||||||
|
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
|
||||||
|
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
|
||||||
|
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
|
||||||
|
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
|
||||||
|
}
|
||||||
|
if (updates.length) editor.updateShapes(updates as any)
|
||||||
|
} catch { /* non-fatal: implicit-confirm is best-effort */ }
|
||||||
|
}, { scope: 'document', source: 'user' })
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* AI review actions — appears while there are unconfirmed suggestions to resolve */}
|
||||||
|
{review.unconfirmed > 0 && (
|
||||||
|
<Paper elevation={6} sx={{ position: 'absolute', left: 16, bottom: 16, zIndex: 1002, px: 1, py: 0.75, borderRadius: 2, bgcolor: 'background.paper', display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Chip size="small" color="warning" label={`${review.unconfirmed} AI unconfirmed`} sx={{ fontWeight: 700 }} />
|
||||||
|
<Tooltip title="Accept the selected AI suggestions (nudging one also confirms it)">
|
||||||
|
<span><Button size="small" color="success" startIcon={<CheckIcon fontSize="small" />} onClick={acceptSelected}>Accept</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Reject (delete) the selected shapes">
|
||||||
|
<span><Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} onClick={rejectSelected}>Reject</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Accept every AI suggestion on the page">
|
||||||
|
<span><Button size="small" variant="contained" startIcon={<DoneAllIcon fontSize="small" />} onClick={acceptAllAi}>Accept all</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
</Box>
|
||||||
|
|
||||||
<Paper elevation={8} sx={{ position: 'absolute', top: 12, left: 12, right: 12, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderRadius: 3, bgcolor: 'background.paper' }}>
|
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
||||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} size="small">Back</Button>
|
|
||||||
<Divider orientation="vertical" flexItem />
|
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · real PDF backdrop is locked; drawn regions persist with PDF-derived page numbers.</Typography>
|
|
||||||
</Box>
|
|
||||||
<Chip size="small" icon={<PictureAsPdfIcon />} color={pdfStatus.includes('loaded') ? 'info' : pdfStatus.includes('unavailable') ? 'warning' : 'default'} label={pdfLoading ? 'Loading PDF…' : pdfStatus} />
|
|
||||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
|
||||||
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper elevation={8} sx={{ position: 'absolute', top: 92, left: 12, p: 1.25, borderRadius: 3, bgcolor: 'background.paper' }}>
|
|
||||||
<Stack spacing={1}>{toolButtons}</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
1) Use the visible PDF pages as the paper coordinate system. 2) Draw top/bottom Boundary lines, Part boxes, then regions over the real paper. 3) Save derives page numbers from the PDF page under each shape and reloads them at the same canvas location.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}
|
|
||||||
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 86, right: 16, maxWidth: 560 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
|
||||||
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
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 { 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 PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
|
|
||||||
@@ -15,95 +19,227 @@ export const SHAPE_TYPES = {
|
|||||||
furniture: 'exam-region-furniture',
|
furniture: 'exam-region-furniture',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type PdfPageTLShape = TLBaseBoxShape<typeof PDF_PAGE_SHAPE_TYPE, {
|
export type ExamPdfPageTLShape = TLBaseBoxShape & {
|
||||||
w: number
|
type: typeof PDF_PAGE_SHAPE_TYPE
|
||||||
h: number
|
props: { w: number; h: number; src: string; pageNumber: number }
|
||||||
src: string
|
|
||||||
pageNumber: number
|
|
||||||
}>
|
|
||||||
|
|
||||||
export type ExamCanvasTLShape = TLBaseBoxShape<typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES], {
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
label: string
|
|
||||||
kind: ExamCanvasShapeKind
|
|
||||||
domainId: string
|
|
||||||
maxMarks?: number
|
|
||||||
responseForm?: string
|
|
||||||
contextType?: string
|
|
||||||
questionId?: string | null
|
|
||||||
}>
|
|
||||||
|
|
||||||
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
|
|
||||||
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
|
|
||||||
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
|
|
||||||
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
|
|
||||||
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
|
|
||||||
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
|
|
||||||
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
|
|
||||||
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
|
|
||||||
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDomainId() {
|
export type ExamCanvasTLShape = TLBaseBoxShape & {
|
||||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
|
||||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
props: {
|
||||||
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid.
|
||||||
|
function ghostOpacity(confidence: number | null | undefined) {
|
||||||
|
if (typeof confidence !== 'number') return 0.72
|
||||||
|
return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isGhost = isAi && shape.props.confirmed === false
|
||||||
|
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={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 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">{isGhost ? '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) {
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
const kind = shape.props.kind
|
const kind = shape.props.kind
|
||||||
const p = palette[kind] ?? palette.response
|
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||||
const isBoundary = kind === 'boundary'
|
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 (
|
return (
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||||
<div style={{
|
<style>{shapeCss}</style>
|
||||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
|
<div
|
||||||
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
|
||||||
background: isBoundary ? 'transparent' : p.fill, color: p.stroke, fontFamily: 'Inter, system-ui, sans-serif',
|
style={{
|
||||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
'--exam-light-stroke': p.stroke,
|
||||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? 'none' : '0 10px 22px rgba(15,23,42,0.08)', overflow: 'hidden'
|
'--exam-light-fill': p.fill,
|
||||||
}}>
|
'--exam-dark-stroke': p.darkStroke,
|
||||||
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
|
'--exam-dark-fill': p.darkFill,
|
||||||
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||||
|
borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
|
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 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">{isAiSuggestion ? 'AI' : p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</span>
|
||||||
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</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>
|
</div>
|
||||||
</HTMLContainer>
|
</HTMLContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||||
const p = palette[kind]
|
const p = canvasShapePalette[kind]
|
||||||
return { w, h, label: p.label, kind, domainId: makeDomainId(), responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
class PdfPageUtil extends BaseBoxShapeUtil<PdfPageTLShape> {
|
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 type = PDF_PAGE_SHAPE_TYPE
|
||||||
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
|
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 getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
|
||||||
override canEdit() { return false }
|
override canEdit() { return false }
|
||||||
override canResize() { return false }
|
override component(shape: ExamPdfPageTLShape) {
|
||||||
override hideResizeHandles() { return true }
|
|
||||||
override hideRotateHandle() { return true }
|
|
||||||
override component(shape: PdfPageTLShape) {
|
|
||||||
return (
|
return (
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||||
<img
|
<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' }} />
|
||||||
alt={`PDF page ${shape.props.pageNumber}`}
|
|
||||||
src={shape.props.src}
|
|
||||||
draggable={false}
|
|
||||||
style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', boxShadow: '0 12px 26px rgba(15,23,42,0.22)', background: '#fff' }}
|
|
||||||
/>
|
|
||||||
</HTMLContainer>
|
</HTMLContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||||
}
|
}
|
||||||
|
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
|
||||||
|
static override type = SHAPE_TYPES.boundary
|
||||||
|
static override props = sharedProps
|
||||||
|
|
||||||
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, domainId: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
|
||||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
override canEdit() { return false }
|
||||||
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } }
|
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 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 PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
|
||||||
@@ -117,6 +253,10 @@ class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.
|
|||||||
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 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 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 {
|
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||||
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||||
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||||
|
|||||||
@@ -1,75 +1,47 @@
|
|||||||
import type { Editor } from '@tldraw/tldraw'
|
import * as pdfjsLib from "pdfjs-dist"
|
||||||
import * as pdfjsLib from 'pdfjs-dist'
|
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url"
|
||||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs?url'
|
|
||||||
|
|
||||||
import { examRepository } from '../../../services/exam/examRepository'
|
import { PAGE_WIDTH } from "../../../utils/exam-canvas/model"
|
||||||
import { PAGE_GAP, PAGE_WIDTH, type PdfPageLayout } from '../../../utils/exam-canvas/model'
|
|
||||||
import { PDF_PAGE_SHAPE_TYPE, type PdfPageTLShape } from './examCanvasShapes'
|
|
||||||
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc
|
||||||
|
|
||||||
export interface PdfBackdropPage {
|
export interface PdfPageImage {
|
||||||
page: number
|
pageNumber: number
|
||||||
src: string
|
src: string
|
||||||
layout: PdfPageLayout
|
width: number
|
||||||
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PdfBackdropResult {
|
export async function loadPdfPageImages(
|
||||||
pages: PdfBackdropPage[]
|
pdfBytes: ArrayBuffer,
|
||||||
pageLayouts: PdfPageLayout[]
|
targetWidth = PAGE_WIDTH,
|
||||||
}
|
onPageReady?: (pages: PdfPageImage[]) => void,
|
||||||
|
): Promise<PdfPageImage[]> {
|
||||||
export async function renderPdfBackdrop(templateId: string): Promise<PdfBackdropResult> {
|
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
|
||||||
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
const pages: PdfPageImage[] = []
|
||||||
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(bytes) })
|
// Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory
|
||||||
const pdf = await loadingTask.promise
|
// for a typical 36-page exam paper.
|
||||||
const pages: PdfBackdropPage[] = []
|
const canvas = document.createElement("canvas")
|
||||||
let y = 0
|
|
||||||
|
|
||||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||||
const page = await pdf.getPage(pageNumber)
|
const page = await pdf.getPage(pageNumber)
|
||||||
const nativeViewport = page.getViewport({ scale: 1 })
|
const baseViewport = page.getViewport({ scale: 1 })
|
||||||
const layoutScale = PAGE_WIDTH / nativeViewport.width
|
const scale = targetWidth / baseViewport.width
|
||||||
const renderScale = layoutScale * Math.min(window.devicePixelRatio || 1, 2)
|
const viewport = page.getViewport({ scale })
|
||||||
const renderViewport = page.getViewport({ scale: renderScale })
|
canvas.width = Math.ceil(viewport.width)
|
||||||
const canvas = document.createElement('canvas')
|
canvas.height = Math.ceil(viewport.height)
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext("2d")
|
||||||
if (!context) throw new Error('Could not create 2D canvas for PDF rendering')
|
if (!context) throw new Error("Unable to create PDF render canvas")
|
||||||
canvas.width = Math.ceil(renderViewport.width)
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
canvas.height = Math.ceil(renderViewport.height)
|
await page.render({ canvasContext: context, viewport }).promise
|
||||||
await page.render({ canvasContext: context, viewport: renderViewport }).promise
|
pages.push({
|
||||||
|
pageNumber,
|
||||||
const layout: PdfPageLayout = {
|
src: canvas.toDataURL("image/png"),
|
||||||
page: pageNumber,
|
width: canvas.width,
|
||||||
x: 0,
|
height: canvas.height,
|
||||||
y,
|
})
|
||||||
w: PAGE_WIDTH,
|
onPageReady?.([...pages])
|
||||||
h: nativeViewport.height * layoutScale,
|
|
||||||
}
|
|
||||||
pages.push({ page: pageNumber, src: canvas.toDataURL('image/jpeg', 0.9), layout })
|
|
||||||
y += layout.h + PAGE_GAP
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await pdf.destroy()
|
return pages
|
||||||
return { pages, pageLayouts: pages.map((p) => p.layout) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replacePdfPageShapes(editor: Editor, pages: PdfBackdropPage[]) {
|
|
||||||
const old = editor.getCurrentPageShapes().filter((shape) => shape.type === PDF_PAGE_SHAPE_TYPE).map((shape) => shape.id)
|
|
||||||
if (old.length) editor.deleteShapes(old)
|
|
||||||
if (!pages.length) return
|
|
||||||
|
|
||||||
editor.createShapes(pages.map((page) => ({
|
|
||||||
id: `shape:pdf-page-${page.page}` as PdfPageTLShape['id'],
|
|
||||||
type: PDF_PAGE_SHAPE_TYPE,
|
|
||||||
x: page.layout.x,
|
|
||||||
y: page.layout.y,
|
|
||||||
isLocked: true,
|
|
||||||
props: {
|
|
||||||
w: page.layout.w,
|
|
||||||
h: page.layout.h,
|
|
||||||
src: page.src,
|
|
||||||
pageNumber: page.page,
|
|
||||||
},
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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';
|
||||||
|
|
||||||
@@ -131,10 +132,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
if (clsRes.id) setCls(clsRes);
|
if (clsRes.id) {
|
||||||
|
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');
|
||||||
@@ -174,20 +185,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddStudent = async (studentId: string) => {
|
const handleAddStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
|
const res = await apiPost(`/database/timetable/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(`/classes/${classId}/students/${studentId}`);
|
await apiDelete(`/database/timetable/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(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
const res = await apiPatch(`/database/timetable/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');
|
||||||
};
|
};
|
||||||
@@ -254,6 +265,8 @@ 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})`} />
|
||||||
|
|||||||
@@ -8,12 +8,32 @@
|
|||||||
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,
|
||||||
|
BankResponse,
|
||||||
|
BatchQueueResponse,
|
||||||
|
BatchResultsResponse,
|
||||||
|
CreateBatchPayload,
|
||||||
|
CreateCustomPaperPayload,
|
||||||
|
CreateCustomPaperResult,
|
||||||
CreateTemplatePayload,
|
CreateTemplatePayload,
|
||||||
|
ExamBoundary,
|
||||||
|
ExamQuestion,
|
||||||
|
ExamResponseArea,
|
||||||
ExamTemplate,
|
ExamTemplate,
|
||||||
ExamTemplateDetail,
|
ExamTemplateDetail,
|
||||||
|
ExamTemplateLayout,
|
||||||
|
MarkingBatch,
|
||||||
|
MarkUpsertPayload,
|
||||||
|
Neo4jSyncResult,
|
||||||
|
PatchQuestionPayload,
|
||||||
|
SpecPoint,
|
||||||
|
StudentSubmission,
|
||||||
TemplateReplacePayload,
|
TemplateReplacePayload,
|
||||||
|
UpdateTemplateMetaPayload,
|
||||||
} from '../../types/exam.types';
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||||
@@ -26,6 +46,113 @@ 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();
|
||||||
@@ -42,28 +169,169 @@ 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();
|
||||||
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> {
|
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
||||||
return res.data;
|
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 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;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBank(params: { specRef?: string; subject?: string } = {}): Promise<BankResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<BankResponse>(`${EXAM_BASE}/bank`, {
|
||||||
|
headers,
|
||||||
|
params: { spec_ref: params.specRef, subject: params.subject },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCustomPaper(payload: CreateCustomPaperPayload): Promise<CreateCustomPaperResult> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<CreateCustomPaperResult>(`${EXAM_BASE}/custom-papers`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadScan(
|
||||||
|
batchId: string,
|
||||||
|
file: File,
|
||||||
|
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
|
||||||
|
): Promise<StudentSubmission> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('matching_method', opts.matchingMethod ?? 'ordered');
|
||||||
|
if (opts.studentId) form.append('student_id', opts.studentId);
|
||||||
|
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
|
||||||
|
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default examRepository;
|
export default examRepository;
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const rawSupabaseUrl = 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');
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-5
@@ -31,7 +31,52 @@ export interface CreateTemplatePayload {
|
|||||||
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;
|
||||||
@@ -41,25 +86,41 @@ 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: Record<string, unknown>;
|
mark_scheme: MarkScheme;
|
||||||
is_container: boolean;
|
is_container: boolean;
|
||||||
spec_ref: string | null;
|
spec_ref: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
page?: 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: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
|
kind: ExamResponseAreaKind;
|
||||||
response_form: string | null;
|
response_form: string | null;
|
||||||
context_type?: 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 {
|
||||||
@@ -70,14 +131,36 @@ export interface ExamBoundary {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds: Record<string, number> | null;
|
bounds: Record<string, number> | null;
|
||||||
source: 'manual' | 'ai';
|
source: ExamTemplateSource;
|
||||||
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +184,10 @@ export interface TemplateReplacePayload {
|
|||||||
spec_ref?: string | null;
|
spec_ref?: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
page?: number | null;
|
page?: number | null;
|
||||||
|
source?: ExamTemplateSource;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
}>;
|
}>;
|
||||||
response_areas: Array<{
|
response_areas: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -113,6 +200,8 @@ export interface TemplateReplacePayload {
|
|||||||
source?: 'manual' | 'ai';
|
source?: 'manual' | 'ai';
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
confidence?: number | null;
|
confidence?: number | null;
|
||||||
|
mark_subtype?: ExamMarkSubtype | null;
|
||||||
|
derivation?: string | null;
|
||||||
}>;
|
}>;
|
||||||
boundaries: Array<{
|
boundaries: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -121,7 +210,164 @@ export interface TemplateReplacePayload {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
source?: 'manual' | 'ai';
|
source?: ExamTemplateSource;
|
||||||
confirmed?: boolean;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mode-3 question bank + custom-paper assembly ──────────────────────────────
|
||||||
|
export interface BankQuestion {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
label: string | null;
|
||||||
|
max_marks: number | null;
|
||||||
|
answer_type: string | null;
|
||||||
|
spec_ref: string | null;
|
||||||
|
bounds: Record<string, number> | null;
|
||||||
|
page: number | null;
|
||||||
|
paper: { id: string | null; title: string | null; subject: string | null; exam_code: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankResponse {
|
||||||
|
questions: BankQuestion[];
|
||||||
|
n: number;
|
||||||
|
facets: { spec_ref: Record<string, number>; subject: Record<string, number> };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomPaperPayload {
|
||||||
|
title: string;
|
||||||
|
subject?: string;
|
||||||
|
question_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomPaperResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string | null;
|
||||||
|
n_questions: number;
|
||||||
|
n_response_areas: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,72 +1,119 @@
|
|||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { ExamTemplateDetail } from '../../types/exam.types'
|
import type { ExamTemplateDetail } from '../../types/exam.types'
|
||||||
import { serializeCanvasShapes, shapesFromTemplate, type PdfPageLayout } from './model'
|
import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model'
|
||||||
|
|
||||||
const template: ExamTemplateDetail = {
|
const template: ExamTemplateDetail = {
|
||||||
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
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: [],
|
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [],
|
||||||
}
|
|
||||||
|
|
||||||
const ids = {
|
|
||||||
top: '11111111-1111-4111-8111-111111111111',
|
|
||||||
bottom: '22222222-2222-4222-8222-222222222222',
|
|
||||||
main: '33333333-3333-4333-8333-333333333333',
|
|
||||||
part: '44444444-4444-4444-8444-444444444444',
|
|
||||||
response: '55555555-5555-4555-8555-555555555555',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('exam setup canvas serialization', () => {
|
describe('exam setup canvas serialization', () => {
|
||||||
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
|
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
|
||||||
const payload = serializeCanvasShapes(template, [
|
const payload = serializeCanvasShapes(template, [
|
||||||
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
{ id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
||||||
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
{ id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
||||||
{ id: ids.part, kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
|
{ id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
|
||||||
{ id: ids.response, kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
|
{ 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 main = payload.questions.find((q) => q.is_container)
|
||||||
const part = payload.questions.find((q) => q.id === ids.part)
|
const part = payload.questions.find((q) => !q.is_container)
|
||||||
expect(main?.label).toBe('Q1')
|
expect(main?.label).toBe('Q1')
|
||||||
expect(part?.parent_id).toBe(main?.id)
|
expect(part?.parent_id).toBe(main?.id)
|
||||||
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
|
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
|
||||||
expect(payload.response_areas[0]).toMatchObject({ id: ids.response, question_id: ids.part, kind: 'response', response_form: 'lines' })
|
expect(payload.response_areas[0]).toMatchObject({ question_id: part?.id, kind: 'response', response_form: 'lines' })
|
||||||
expect(payload.boundaries).toHaveLength(2)
|
expect(payload.boundaries).toHaveLength(2)
|
||||||
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true)
|
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('uses real PDF page layouts to save visual page numbers', () => {
|
it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => {
|
||||||
const layouts: PdfPageLayout[] = [
|
const pages = [
|
||||||
{ page: 1, x: 0, y: 0, w: 780, h: 1000 },
|
{ pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 },
|
||||||
{ page: 2, x: 0, y: 1040, w: 780, h: 980 },
|
{ pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 },
|
||||||
]
|
]
|
||||||
|
expect(pageForY(1050, pages)).toBe(2)
|
||||||
const payload = serializeCanvasShapes(template, [
|
const payload = serializeCanvasShapes(template, [
|
||||||
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 1060, w: 700, h: 8, label: 'Q2 start' },
|
{ id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' },
|
||||||
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 1800, w: 700, h: 8, label: 'Q2 end' },
|
{ id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' },
|
||||||
{ id: ids.part, kind: 'part', x: 100, y: 1120, w: 400, h: 220, label: 'Q2(a)', maxMarks: 3 },
|
{ id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' },
|
||||||
{ id: ids.response, kind: 'response', x: 130, y: 1250, w: 300, h: 90, responseForm: 'lines' },
|
{ id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 },
|
||||||
], layouts)
|
], pages)
|
||||||
|
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
||||||
expect(payload.boundaries.map((b) => b.page_index)).toEqual([1, 1])
|
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
||||||
expect(payload.questions.find((q) => q.id === ids.part)?.page).toBe(2)
|
expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
|
||||||
expect(payload.response_areas[0].page).toBe(2)
|
expect(payload.response_areas[0].page).toBe(2)
|
||||||
expect(payload.meta?.page_count).toBe(2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
|
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
|
||||||
const shapes = shapesFromTemplate({
|
const shapes = shapesFromTemplate({
|
||||||
...template,
|
...template,
|
||||||
questions: [
|
questions: [
|
||||||
{ id: ids.main, 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 },
|
{ 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: ids.part, template_id: 'tpl-1', parent_id: ids.main, 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 },
|
{ 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: [
|
response_areas: [
|
||||||
{ id: ids.response, question_id: ids.part, 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 },
|
{ 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: '66666666-6666-4666-8666-666666666666', question_id: ids.part, 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 },
|
{ 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: ids.top, template_id: 'tpl-1', question_id: ids.main, label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
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.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: ids.part, x: 1, y: 2, w: 3, h: 4 })
|
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' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never drops a response region that has no enclosing part — it falls back to a question', () => {
|
||||||
|
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: 120, label: 'Q1(a)', maxMarks: 3 },
|
||||||
|
{ id: 'resp-far', kind: 'response', x: 100, y: 520, w: 300, h: 90, responseForm: 'lines' }, // outside the part box
|
||||||
|
])
|
||||||
|
const part = payload.questions.find((q) => !q.is_container)
|
||||||
|
expect(payload.response_areas).toHaveLength(1) // previously dropped (no containing part)
|
||||||
|
expect(payload.response_areas[0].question_id).toBe(part?.id) // falls back to the part, not lost
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects a region persisted question over geometric nearest when no part contains it', () => {
|
||||||
|
const A = '11111111-1111-4111-8111-111111111111'
|
||||||
|
const B = '22222222-2222-4222-8222-222222222222'
|
||||||
|
const R = '33333333-3333-4333-8333-333333333333'
|
||||||
|
const payload = serializeCanvasShapes(template, [
|
||||||
|
{ id: 'pa', kind: 'part', x: 100, y: 120, w: 300, h: 80, label: 'A', questionId: A },
|
||||||
|
{ id: 'pb', kind: 'part', x: 100, y: 500, w: 300, h: 80, label: 'B', questionId: B },
|
||||||
|
{ id: R, kind: 'response', x: 120, y: 260, w: 200, h: 60, questionId: B }, // contained by neither; nearest is A
|
||||||
|
])
|
||||||
|
expect(payload.response_areas.find((r) => r.id === R)?.question_id).toBe(B) // persisted B wins over nearest A
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
+127
-57
@@ -1,18 +1,19 @@
|
|||||||
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
|
|
||||||
|
|
||||||
|
import type { ExamTemplateDetail, ExamTemplateSource, TemplateReplacePayload } from '../../types/exam.types'
|
||||||
|
|
||||||
|
export const PAGE_HEIGHT = 1100
|
||||||
export const PAGE_WIDTH = 780
|
export const PAGE_WIDTH = 780
|
||||||
export const DEFAULT_PAGE_HEIGHT = 1100
|
export const PAGE_GAP = 0
|
||||||
export const PAGE_GAP = 40
|
|
||||||
// Back-compat alias for S4-9a callers/tests. Real PDF pages use PdfPageLayout heights.
|
export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number }
|
||||||
export const PAGE_HEIGHT = DEFAULT_PAGE_HEIGHT + PAGE_GAP
|
|
||||||
|
|
||||||
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||||
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||||
|
|
||||||
export interface CanvasBounds { x: number; y: number; w: number; h: number }
|
export interface CanvasBounds extends Record<string, number> { x: number; y: number; w: number; h: number }
|
||||||
export interface PdfPageLayout { page: number; x: number; y: number; w: number; h: number }
|
|
||||||
|
|
||||||
export interface ExamCanvasShapeModel {
|
export interface ExamCanvasShapeModel {
|
||||||
|
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
||||||
id: string
|
id: string
|
||||||
kind: ExamCanvasShapeKind
|
kind: ExamCanvasShapeKind
|
||||||
x: number
|
x: number
|
||||||
@@ -25,37 +26,79 @@ export interface ExamCanvasShapeModel {
|
|||||||
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
||||||
contextType?: string
|
contextType?: string
|
||||||
questionId?: string | null
|
questionId?: string | null
|
||||||
|
source?: ExamTemplateSource
|
||||||
|
confirmed?: boolean
|
||||||
|
confidence?: number | null
|
||||||
|
derivation?: string | null
|
||||||
|
reviewFlags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fallbackPageLayouts(pageCount = 4): PdfPageLayout[] {
|
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
||||||
return Array.from({ length: Math.max(pageCount, 1) }, (_, i) => ({
|
if (pages?.length) {
|
||||||
page: i + 1,
|
const hit = pages.find((page) => y >= page.y && y <= page.y + page.h)
|
||||||
x: 0,
|
if (hit) return hit.pageNumber
|
||||||
y: i * PAGE_HEIGHT,
|
const nearest = pages.reduce((best, page) => {
|
||||||
w: PAGE_WIDTH,
|
const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h)))
|
||||||
h: DEFAULT_PAGE_HEIGHT,
|
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 pageForY(y: number, layouts?: PdfPageLayout[]): number {
|
export function pageTop(page: number, pages?: CanvasPageGeometry[]): number {
|
||||||
if (!layouts?.length) return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
const hit = pages?.find((p) => p.pageNumber === page)
|
||||||
const hit = layouts.find((layout) => y >= layout.y && y <= layout.y + layout.h)
|
return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP))
|
||||||
if (hit) return hit.page
|
|
||||||
const nearest = layouts.reduce((best, layout) => {
|
|
||||||
const dist = y < layout.y ? layout.y - y : y - (layout.y + layout.h)
|
|
||||||
return dist < best.dist ? { page: layout.page, dist } : best
|
|
||||||
}, { page: layouts[0].page, dist: Number.POSITIVE_INFINITY })
|
|
||||||
return nearest.page
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, layouts?: PdfPageLayout[]): number {
|
function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number {
|
||||||
return pageForY(shape.y + shape.h / 2, layouts)
|
return pageForY(shape.y + shape.h / 2, pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
|
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 }
|
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 {
|
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
||||||
const ox2 = outer.x + outer.w
|
const ox2 = outer.x + outer.w
|
||||||
const oy2 = outer.y + outer.h
|
const oy2 = outer.y + outer.h
|
||||||
@@ -71,21 +114,10 @@ function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, s
|
|||||||
return cy >= minY && cy <= maxY
|
return cy >= minY && cy <= maxY
|
||||||
}
|
}
|
||||||
|
|
||||||
function uuid(): string {
|
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
|
||||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
|
||||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
|
||||||
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stableUuid(id?: string | null): string {
|
|
||||||
return id && /^[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(id) ? id : uuid()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pageLayouts?: PdfPageLayout[]): TemplateReplacePayload {
|
|
||||||
const orderedBoundaries = shapes
|
const orderedBoundaries = shapes
|
||||||
.filter((s) => s.kind === 'boundary')
|
.filter((s) => s.kind === 'boundary')
|
||||||
.sort((a, b) => (pageForShape(a, pageLayouts) - pageForShape(b, pageLayouts)) || (a.y - b.y))
|
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
|
||||||
const parts = shapes.filter((s) => s.kind === 'part')
|
const parts = shapes.filter((s) => s.kind === 'part')
|
||||||
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
||||||
|
|
||||||
@@ -98,52 +130,90 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const bottom = orderedBoundaries[i + 1]
|
const bottom = orderedBoundaries[i + 1]
|
||||||
if (!top || !bottom) break
|
if (!top || !bottom) break
|
||||||
const qNum = bands.length + 1
|
const qNum = bands.length + 1
|
||||||
const questionId = stableUuid(top.questionId || bottom.questionId)
|
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}`
|
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: {} })
|
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 })
|
bands.push({ questionId, top, bottom })
|
||||||
for (const b of [top, bottom]) {
|
for (const b of [top, bottom]) {
|
||||||
boundaries.push({ id: stableUuid(b.id), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pageLayouts) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
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>()
|
const partQuestionIds = new Map<string, string>()
|
||||||
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
|
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 parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||||
const qid = stableUuid(part.questionId || part.id)
|
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
||||||
partQuestionIds.set(part.id, qid)
|
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, pageLayouts) })
|
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) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Resolve each region's owner question. Order: current geometric containment (a user who drags a
|
||||||
|
// region into a part re-attaches it) → the PERSISTED attachment if it still points at a saved
|
||||||
|
// question (survives pixel-overflow drift and regions with no enclosing part) → nearest part on the
|
||||||
|
// page → first part → first question of any kind. exam_response_areas.question_id is NOT NULL, so we
|
||||||
|
// never silently drop a region while any question exists (only a template with zero questions can).
|
||||||
|
const questionIds = new Set(questions.map((q) => q.id))
|
||||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||||
for (const region of regions) {
|
for (const region of regions) {
|
||||||
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||||
const regionPage = pageForShape(region, pageLayouts)
|
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
|
||||||
const fallbackPart = parts.find((part) => pageForShape(part, pageLayouts) === regionPage) ?? parts[0]
|
const nearestPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
const questionId =
|
||||||
|
(containingPart && partQuestionIds.get(containingPart.id))
|
||||||
|
|| persisted
|
||||||
|
|| (nearestPart && partQuestionIds.get(nearestPart.id))
|
||||||
|
|| questions[0]?.id
|
||||||
if (!questionId) continue
|
if (!questionId) continue
|
||||||
const kind = region.kind as ExamCanvasRegionKind
|
const kind = region.kind as ExamCanvasRegionKind
|
||||||
response_areas.push({ id: stableUuid(region.id), question_id: questionId, page: regionPage, bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
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: pageLayouts?.length ?? template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
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): ExamCanvasShapeModel[] {
|
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||||
const shapes: ExamCanvasShapeModel[] = []
|
const shapes: ExamCanvasShapeModel[] = []
|
||||||
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||||
for (const b of detail.boundaries ?? []) {
|
for (const b of detail.boundaries ?? []) {
|
||||||
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
|
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
|
||||||
shapes.push({ id: stableUuid(b.id), kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
|
// 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 ?? []) {
|
for (const q of detail.questions ?? []) {
|
||||||
if (q.is_container || !q.bounds) continue
|
if (q.is_container || !q.bounds) continue
|
||||||
shapes.push({ id: stableUuid(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 ?? 'written', questionId: q.id })
|
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 ?? []) {
|
for (const r of detail.response_areas ?? []) {
|
||||||
const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 }
|
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
||||||
const q = questions.get(r.question_id)
|
const q = questions.get(r.question_id)
|
||||||
shapes.push({ id: stableUuid(r.id), kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: 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 shapes
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user