From 7a1dd4dcccfa7b7f25dbac6f130eec6997e06e7e Mon Sep 17 00:00:00 2001 From: CC Worker Date: Thu, 2 Jul 2026 21:44:04 +0000 Subject: [PATCH] FX-5: scan-ingestion UI on the marking page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marking flow read student_submissions but there was no way to create them — print→scan→mark could not be completed in-app. Add an "Upload scans" control to the marking queue that posts PDFs to the existing POST /batches/{id}/scans and refreshes the queue. - examRepository.uploadScan(batchId, file, {studentId?, matchingMethod?}): multipart upload (axios sets the boundary), returns the created/updated StudentSubmission. - ExamMarkingPage: "Upload scans" button + hidden multi-file PDF input; uploads each with matching_method='ordered' (fills the roster in order; unmatched scans become reconcilable submissions), then reloads the queue with a success/failure summary. No API or schema change (endpoint already existed). tsc clean; behaviour is covered by the cc-browser-tester card (FX-V). Co-Authored-By: Claude Opus 4.8 --- src/pages/exam/ExamMarkingPage.tsx | 51 +++++++++++++++++++++++++++-- src/services/exam/examRepository.ts | 16 +++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/pages/exam/ExamMarkingPage.tsx b/src/pages/exam/ExamMarkingPage.tsx index 60a17e2..f34082d 100644 --- a/src/pages/exam/ExamMarkingPage.tsx +++ b/src/pages/exam/ExamMarkingPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { v5 as uuidv5 } from 'uuid'; import { @@ -21,6 +21,7 @@ import { 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'; @@ -43,6 +44,8 @@ const ExamMarkingPage: React.FC = () => { const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); const load = useCallback(async () => { if (!batchId) return; @@ -68,6 +71,28 @@ const ExamMarkingPage: React.FC = () => { () => (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 () => { @@ -154,7 +179,29 @@ const ExamMarkingPage: React.FC = () => { - Marking queue + + Marking queue + + { void onScanFiles(e.target.files); }} + /> + + + Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile. + diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index 709b425..d3bd2d6 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -28,6 +28,7 @@ import type { Neo4jSyncResult, PatchQuestionPayload, SpecPoint, + StudentSubmission, TemplateReplacePayload, UpdateTemplateMetaPayload, } from '../../types/exam.types'; @@ -298,6 +299,21 @@ export const examRepository = { const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers }); return res.data; }, + + async uploadScan( + batchId: string, + file: File, + opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {}, + ): Promise { + 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(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers }); + return res.data; + }, }; export default examRepository;