Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db852aaff | ||
|
|
7326b9f3be | ||
|
|
cd8ac38d39 | ||
|
|
e899af303d | ||
|
|
7a01b3e8f6 | ||
|
|
bff91a4b17 | ||
|
|
66f35b8ae4 | ||
|
|
fe5dbe7fa8 | ||
|
|
15a519748d | ||
|
|
3389fdcb5b | ||
|
|
ab35193be1 | ||
|
|
b3f71c5749 |
+49
-7
@@ -7,13 +7,37 @@ 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
|
||||||
@@ -31,6 +55,24 @@ 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 /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; \
|
||||||
|
|||||||
+17
-1
@@ -16,7 +16,23 @@ 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.
|
||||||
|
# .env.dev still points at the LAN API for local Vite/dev tooling.
|
||||||
|
VITE_API_BASE: /__ccapi
|
||||||
|
VITE_API_URL: /__ccapi
|
||||||
|
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_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:-}
|
||||||
env_file:
|
env_file:
|
||||||
- .env.dev
|
- .env.dev
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ vi.mock('./pages/exam', () => ({
|
|||||||
ExamDashboardPage: () => <div>Exam Marker</div>,
|
ExamDashboardPage: () => <div>Exam Marker</div>,
|
||||||
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
|
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
|
||||||
MarkSchemePage: () => <div>Mark Scheme editor</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> }));
|
||||||
|
|||||||
+4
-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, MarkSchemePage } from './pages/exam';
|
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } 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 />} />
|
||||||
@@ -185,6 +186,8 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||||
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></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 />} />
|
||||||
|
|||||||
@@ -315,20 +315,20 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
{t.exam_code && (
|
{t.exam_code && (
|
||||||
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
||||||
)}
|
)}
|
||||||
<Stack spacing={1} sx={{ mt: 'auto', pt: 1 }}>
|
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||||
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
<Typography variant="caption" color="text.secondary">
|
||||||
<Typography variant="caption" color="text.secondary">
|
Updated {new Date(t.updated_at).toLocaleDateString()}
|
||||||
Updated {new Date(t.updated_at).toLocaleDateString()}
|
</Typography>
|
||||||
</Typography>
|
</Box>
|
||||||
</Box>
|
<Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
||||||
startIcon={<GradingIcon fontSize="small" />}
|
startIcon={<GradingIcon fontSize="small" />}
|
||||||
>
|
>
|
||||||
Mark scheme
|
Edit marks
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { v5 as uuidv5 } from 'uuid';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||||
|
|
||||||
|
const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0';
|
||||||
|
|
||||||
|
function stableMarkId(batchId: string, submissionId: string, questionId: string) {
|
||||||
|
return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamMarkingPage: React.FC = () => {
|
||||||
|
const { batchId } = useParams<{ batchId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [queue, setQueue] = useState<BatchQueueResponse | null>(null);
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [marks, setMarks] = useState<Record<string, string>>({});
|
||||||
|
const [comments, setComments] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!batchId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const nextQueue = await examRepository.getBatchQueue(batchId);
|
||||||
|
setQueue(nextQueue);
|
||||||
|
setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id));
|
||||||
|
setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [batchId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const markableQuestions = useMemo(
|
||||||
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
||||||
|
[template],
|
||||||
|
);
|
||||||
|
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
const saveSelected = async () => {
|
||||||
|
if (!batchId || !selected) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const writes = markableQuestions
|
||||||
|
.map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] }))
|
||||||
|
.filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim());
|
||||||
|
for (const { q, raw, comment } of writes) {
|
||||||
|
const awarded = raw === undefined || raw === '' ? 0 : Number(raw);
|
||||||
|
if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) {
|
||||||
|
throw new Error(`Invalid mark for ${q.label}`);
|
||||||
|
}
|
||||||
|
await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), {
|
||||||
|
submission_id: selected.id,
|
||||||
|
question_id: q.id,
|
||||||
|
awarded_marks: awarded,
|
||||||
|
comment: comment?.trim() || undefined,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`);
|
||||||
|
setMarks({});
|
||||||
|
setComments({});
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseStudent = (submission: StudentSubmission) => {
|
||||||
|
setSelectedId(submission.id);
|
||||||
|
setMarks({});
|
||||||
|
setComments({});
|
||||||
|
setMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !queue) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
||||||
|
Back to Exam Marker
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h4" component="h1" fontWeight={700}>{queue?.batch.title || 'Mark exam'}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="outlined" startIcon={<TableChartIcon />} onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
|
||||||
|
Results
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
{message && <Alert severity="success" onClose={() => setMessage(null)}>{message}</Alert>}
|
||||||
|
{markableQuestions.length === 0 && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
This template has no markable parts yet. Add parts in template setup before entering marks.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||||
|
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Marking queue</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
||||||
|
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||||
|
<Chip size="small" label={`${queue?.progress.complete ?? 0} complete`} color="success" variant="outlined" />
|
||||||
|
</Stack>
|
||||||
|
<List dense disablePadding>
|
||||||
|
{(queue?.submissions ?? []).map((submission) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={submission.id}
|
||||||
|
selected={submission.id === selectedId}
|
||||||
|
onClick={() => chooseStudent(submission)}
|
||||||
|
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={submission.student_name || submission.student_id || 'Unknown student'}
|
||||||
|
secondary={`${submission.status} · ${submission.mark_entry_count ?? 0} marks`}
|
||||||
|
/>
|
||||||
|
<Chip size="small" label={submission.status} color={submission.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="outlined" sx={{ flex: 1 }}>
|
||||||
|
<CardContent>
|
||||||
|
{selected ? (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">{selected.student_name || selected.student_id || 'Unknown student'}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Status: {selected.status}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
{markableQuestions.map((q: ExamQuestion) => (
|
||||||
|
<Stack key={q.id} direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||||
|
<Box sx={{ minWidth: 150 }}>
|
||||||
|
<Typography variant="body2" fontWeight={700}>{q.label}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">/ {q.max_marks} marks</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Mark"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0, max: q.max_marks, step: 0.5 }}
|
||||||
|
value={marks[q.id] ?? ''}
|
||||||
|
onChange={(e) => setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
||||||
|
sx={{ width: { xs: '100%', sm: 120 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Comment"
|
||||||
|
size="small"
|
||||||
|
value={comments[q.id] ?? ''}
|
||||||
|
onChange={(e) => setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button onClick={() => navigate(`/exam-marker/${batchId}/results`)}>Skip to results</Button>
|
||||||
|
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
|
||||||
|
{saving ? 'Saving…' : 'Save marks'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No submissions in this batch.</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamMarkingPage;
|
||||||
@@ -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,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;
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
export { default as ExamDashboardPage } from ./ExamDashboardPage;
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
export { default as ExamTemplateSetupPage } from ./setup/ExamTemplateSetupPage;
|
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||||
export { default as MarkSchemePage } from ./MarkSchemePage;
|
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,8 +1,9 @@
|
|||||||
|
|
||||||
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 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 '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
@@ -29,7 +30,7 @@ const TOOLS = [
|
|||||||
{ 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 },
|
{ 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 = 260
|
const PAGE_START_X = 0
|
||||||
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
||||||
|
|
||||||
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
||||||
@@ -41,6 +42,22 @@ function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -88,10 +105,15 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | 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[]) {
|
||||||
|
if (!models.length) return
|
||||||
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)
|
||||||
if (!models.length) return
|
|
||||||
editor.createShapes(models.map((m) => ({
|
editor.createShapes(models.map((m) => ({
|
||||||
id: createShapeId(m.id),
|
id: createShapeId(m.id),
|
||||||
type: SHAPE_TYPES[m.kind],
|
type: SHAPE_TYPES[m.kind],
|
||||||
@@ -117,8 +139,7 @@ function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
|||||||
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
|
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
|
||||||
} as any
|
} as any
|
||||||
}))
|
}))
|
||||||
const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber))
|
// z-order is enforced by the caller via bringDomainShapesToFront
|
||||||
try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedGuide(editor: Editor) {
|
function seedGuide(editor: Editor) {
|
||||||
@@ -148,6 +169,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const [activeTool, setActiveTool] = useState('select')
|
const [activeTool, setActiveTool] = useState('select')
|
||||||
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
||||||
const [pdfError, setPdfError] = useState<string | null>(null)
|
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
@@ -170,8 +192,9 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
|
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
|
||||||
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
|
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])
|
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])
|
||||||
try { ed.sendToBack([shapeId as any]) } catch { /* */ }
|
bringDomainShapesToFront(ed)
|
||||||
}
|
}
|
||||||
|
applyDocViewConstraints(ed, partialPages)
|
||||||
}
|
}
|
||||||
setPdfStatus('ready')
|
setPdfStatus('ready')
|
||||||
})
|
})
|
||||||
@@ -188,6 +211,9 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
if (editor) {
|
if (editor) {
|
||||||
syncPdfPages(editor, pages)
|
syncPdfPages(editor, pages)
|
||||||
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
applyDocViewConstraints(editor, pages)
|
||||||
|
editor.resetZoom()
|
||||||
}
|
}
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -243,60 +269,83 @@ 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 }}>
|
||||||
tools={examCanvasTools as any}
|
<Tooltip title="Back to exam marker">
|
||||||
hideUi
|
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
|
||||||
inferDarkMode={theme.palette.mode === 'dark'}
|
</Tooltip>
|
||||||
autoFocus
|
<Divider orientation="vertical" flexItem />
|
||||||
onMount={(editor) => {
|
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
||||||
editorRef.current = editor
|
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
</Paper>
|
||||||
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
|
||||||
}}
|
{/* Body row */}
|
||||||
/>
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Left tool sidebar */}
|
||||||
|
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
||||||
|
<Stack spacing={1}>{toolButtons}</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Canvas area */}
|
||||||
|
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
|
||||||
|
<Tldraw
|
||||||
|
shapeUtils={examCanvasShapeUtils as any}
|
||||||
|
tools={examCanvasTools as any}
|
||||||
|
hideUi
|
||||||
|
inferDarkMode={theme.palette.mode === 'dark'}
|
||||||
|
autoFocus
|
||||||
|
onMount={(editor) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||||
|
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||||
|
applyDocViewConstraints(editor, [])
|
||||||
|
editor.resetZoom()
|
||||||
|
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Guide toggle */}
|
||||||
|
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
||||||
|
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
||||||
|
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Guide panel — collapsible */}
|
||||||
|
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
|
||||||
|
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
|
||||||
|
</Typography>
|
||||||
|
<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>
|
||||||
|
</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 · coloured tools map to persisted regions; boundary start/end pairs can span pages.</Typography>
|
|
||||||
</Box>
|
|
||||||
<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: 460, 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) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
|
|
||||||
</Typography>
|
|
||||||
<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="text.secondary" display="block" sx={{ mt: 0.75 }}>Open design choices resolved for v1: labels use “Q start/end”; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit.</Typography>
|
|
||||||
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
|
||||||
PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'}
|
|
||||||
</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,8 @@
|
|||||||
|
|
||||||
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'
|
||||||
|
|
||||||
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
@@ -65,10 +67,28 @@ const shapeCss = `
|
|||||||
[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__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); }
|
||||||
`
|
`
|
||||||
|
|
||||||
|
function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
||||||
|
const p = canvasShapePalette.boundary
|
||||||
|
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
||||||
|
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={p.dash} strokeLinecap="round" 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">{p.icon}</span>
|
||||||
|
{shape.props.label || p.label}
|
||||||
|
</span>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function renderShape(shape: ExamCanvasTLShape) {
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
const kind = shape.props.kind
|
const kind = shape.props.kind
|
||||||
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||||
const isBoundary = kind === 'boundary'
|
const isBoundary = kind === 'boundary'
|
||||||
|
if (isBoundary) return renderBoundaryLine(shape)
|
||||||
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' }}>
|
||||||
<style>{shapeCss}</style>
|
<style>{shapeCss}</style>
|
||||||
@@ -121,7 +141,55 @@ class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
|||||||
}
|
}
|
||||||
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||||
}
|
}
|
||||||
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
|
||||||
|
static override type = SHAPE_TYPES.boundary
|
||||||
|
static override props = sharedProps
|
||||||
|
|
||||||
|
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
|
||||||
|
override canEdit() { return false }
|
||||||
|
override canResize() { return false }
|
||||||
|
override canBind() { return false }
|
||||||
|
override hideResizeHandles() { return true }
|
||||||
|
override hideRotateHandle() { return true }
|
||||||
|
override hideSelectionBoundsBg() { return true }
|
||||||
|
|
||||||
|
private pageSpanForY(y: number) {
|
||||||
|
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
|
||||||
|
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
|
||||||
|
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
|
||||||
|
if (!best) return page
|
||||||
|
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
|
||||||
|
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
|
||||||
|
return pageDy < bestDy ? page : best
|
||||||
|
}, null)
|
||||||
|
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
|
||||||
|
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
|
||||||
|
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override getGeometry(shape: ExamCanvasTLShape) {
|
||||||
|
const y = shape.props.h / 2
|
||||||
|
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
|
||||||
|
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
|
||||||
|
}
|
||||||
|
|
||||||
|
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
|
||||||
|
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
|
||||||
|
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
|
||||||
|
return this.normalize({ ...current, x: initial.x })
|
||||||
|
}
|
||||||
|
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
|
||||||
|
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
|
||||||
|
}
|
||||||
|
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
|
||||||
|
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
|
||||||
|
}
|
||||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
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) } } }
|
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) } } }
|
||||||
|
|
||||||
|
|||||||
@@ -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})`} />
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ import { API_BASE } from '../../config/apiConfig';
|
|||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import type {
|
import type {
|
||||||
|
BatchQueueResponse,
|
||||||
|
BatchResultsResponse,
|
||||||
|
CreateBatchPayload,
|
||||||
CreateTemplatePayload,
|
CreateTemplatePayload,
|
||||||
ExamBoundary,
|
ExamBoundary,
|
||||||
ExamQuestion,
|
ExamQuestion,
|
||||||
ExamResponseArea,
|
ExamResponseArea,
|
||||||
ExamTemplate,
|
ExamTemplate,
|
||||||
ExamTemplateDetail,
|
ExamTemplateDetail,
|
||||||
|
MarkingBatch,
|
||||||
|
MarkUpsertPayload,
|
||||||
Neo4jSyncResult,
|
Neo4jSyncResult,
|
||||||
PatchQuestionPayload,
|
PatchQuestionPayload,
|
||||||
SpecPoint,
|
SpecPoint,
|
||||||
@@ -209,6 +214,48 @@ export const examRepository = {
|
|||||||
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
|
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
include_archived: params.includeArchived ?? false,
|
||||||
|
template_id: params.templateId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data.batches ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchQueue(batchId: string): Promise<BatchQueueResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchCsv(batchId: string): Promise<string> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default examRepository;
|
export default examRepository;
|
||||||
|
|||||||
+119
-53
@@ -4,7 +4,7 @@
|
|||||||
* the shared UUIDs (template/question/region ids, exam_code).
|
* the shared UUIDs (template/question/region ids, exam_code).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ExamTemplateStatus = draft | ready | archived;
|
export type ExamTemplateStatus = 'draft' | 'ready' | 'archived';
|
||||||
|
|
||||||
export interface ExamTemplate {
|
export interface ExamTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,7 +31,7 @@ export interface CreateTemplatePayload {
|
|||||||
institute_id?: string;
|
institute_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarkSchemeType = points | levels | parts | checklist | free;
|
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
|
||||||
|
|
||||||
export interface MarkSchemePoint {
|
export interface MarkSchemePoint {
|
||||||
mark: number;
|
mark: number;
|
||||||
@@ -92,12 +92,12 @@ export interface ExamQuestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ExamResponseAreaKind =
|
export type ExamResponseAreaKind =
|
||||||
| response
|
| 'response'
|
||||||
| context
|
| 'context'
|
||||||
| question_number
|
| 'question_number'
|
||||||
| mark_area
|
| 'mark_area'
|
||||||
| reference
|
| 'reference'
|
||||||
| furniture;
|
| 'furniture';
|
||||||
|
|
||||||
export interface ExamResponseArea {
|
export interface ExamResponseArea {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -108,7 +108,7 @@ export interface ExamResponseArea {
|
|||||||
kind: ExamResponseAreaKind;
|
kind: ExamResponseAreaKind;
|
||||||
response_form: string | null;
|
response_form: string | null;
|
||||||
context_type?: string | null;
|
context_type?: string | null;
|
||||||
source: manual | ai;
|
source: 'manual' | 'ai';
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
confidence: number | null;
|
confidence: number | null;
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ 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: 'manual' | 'ai';
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +131,57 @@ export interface ExamTemplateDetail extends ExamTemplate {
|
|||||||
boundaries: ExamBoundary[];
|
boundaries: ExamBoundary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TemplateReplacePayload {
|
||||||
|
meta?: {
|
||||||
|
title?: string;
|
||||||
|
subject?: string;
|
||||||
|
page_count?: number;
|
||||||
|
status?: ExamTemplateStatus;
|
||||||
|
};
|
||||||
|
questions: Array<{
|
||||||
|
id?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
order?: number;
|
||||||
|
max_marks?: number;
|
||||||
|
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||||
|
mcq_options?: unknown | null;
|
||||||
|
mark_scheme?: Record<string, unknown>;
|
||||||
|
is_container?: boolean;
|
||||||
|
spec_ref?: string | null;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
page?: number | null;
|
||||||
|
}>;
|
||||||
|
response_areas: Array<{
|
||||||
|
id?: string;
|
||||||
|
question_id: string;
|
||||||
|
page: number;
|
||||||
|
bounds: Record<string, number>;
|
||||||
|
kind: ExamResponseArea['kind'];
|
||||||
|
response_form?: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
|
source?: 'manual' | 'ai';
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
}>;
|
||||||
|
boundaries: Array<{
|
||||||
|
id?: string;
|
||||||
|
question_id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
page_index: number;
|
||||||
|
y: number;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
source?: 'manual' | 'ai';
|
||||||
|
confirmed?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PatchQuestionPayload {
|
export interface PatchQuestionPayload {
|
||||||
label?: string;
|
label?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
max_marks?: number;
|
max_marks?: number;
|
||||||
answer_type?: written | mcq | short | diagram | null;
|
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||||
mcq_options?: unknown;
|
mcq_options?: unknown;
|
||||||
mark_scheme?: MarkScheme;
|
mark_scheme?: MarkScheme;
|
||||||
is_container?: boolean;
|
is_container?: boolean;
|
||||||
@@ -156,47 +202,67 @@ export interface Neo4jSyncResult {
|
|||||||
projection?: Record<string, unknown>;
|
projection?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateReplacePayload {
|
export interface MarkingBatch {
|
||||||
meta?: {
|
id: string;
|
||||||
title?: string;
|
template_id: string;
|
||||||
subject?: string;
|
class_id: string | null;
|
||||||
page_count?: number;
|
institute_id: string;
|
||||||
status?: ExamTemplateStatus;
|
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;
|
||||||
};
|
};
|
||||||
questions: Array<{
|
}
|
||||||
id?: string;
|
|
||||||
parent_id?: string | null;
|
export interface ExamResultRow {
|
||||||
label: string;
|
submission_id: string;
|
||||||
order?: number;
|
student_id: string | null;
|
||||||
max_marks?: number;
|
student_name: string | null;
|
||||||
answer_type?: written | mcq | short | diagram | null;
|
status: string | null;
|
||||||
mcq_options?: unknown | null;
|
marks: Record<string, number | null | undefined>;
|
||||||
mark_scheme?: MarkScheme;
|
total: number | null;
|
||||||
is_container?: boolean;
|
}
|
||||||
spec_ref?: string | null;
|
|
||||||
bounds?: Record<string, number> | null;
|
export interface BatchResultsResponse {
|
||||||
page?: number | null;
|
batch: MarkingBatch;
|
||||||
}>;
|
questions: Array<Pick<ExamQuestion, 'id' | 'label' | 'max_marks' | 'order'>>;
|
||||||
response_areas: Array<{
|
results: ExamResultRow[];
|
||||||
id?: string;
|
}
|
||||||
question_id: string;
|
|
||||||
page: number;
|
export interface CreateBatchPayload {
|
||||||
bounds: Record<string, number>;
|
template_id: string;
|
||||||
kind: ExamResponseAreaKind;
|
class_id?: string;
|
||||||
response_form?: string | null;
|
title?: string;
|
||||||
context_type?: string | null;
|
}
|
||||||
source?: manual | ai;
|
|
||||||
confirmed?: boolean;
|
export interface MarkUpsertPayload {
|
||||||
confidence?: number | null;
|
submission_id: string;
|
||||||
}>;
|
question_id: string;
|
||||||
boundaries: Array<{
|
awarded_marks: number;
|
||||||
id?: string;
|
mark_scheme_detail?: Record<string, unknown>;
|
||||||
question_id?: string | null;
|
annotation_shape_ids?: unknown;
|
||||||
label?: string | null;
|
comment?: string;
|
||||||
page_index: number;
|
confirmed?: boolean;
|
||||||
y: number;
|
|
||||||
bounds?: Record<string, number> | null;
|
|
||||||
source?: manual | ai;
|
|
||||||
confirmed?: boolean;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe('exam setup canvas serialization', () => {
|
|||||||
], pages)
|
], pages)
|
||||||
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
||||||
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
||||||
|
expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
|
||||||
expect(payload.response_areas[0].page).toBe(2)
|
expect(payload.response_areas[0].page).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ describe('exam setup canvas serialization', () => {
|
|||||||
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 }],
|
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 }],
|
||||||
})
|
})
|
||||||
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 === '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 })
|
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,10 +65,19 @@ export function newDomainId(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
|
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 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
|
||||||
@@ -105,7 +114,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
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: {} })
|
||||||
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: 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: 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: 'manual', confirmed: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,8 +143,10 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag
|
|||||||
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: 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 })
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user