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;