FX-5: scan-ingestion UI on the marking page

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 <noreply@anthropic.com>
This commit is contained in:
CC Worker 2026-07-02 21:44:04 +00:00
parent 7bd66fbaf0
commit 7a1dd4dccc
2 changed files with 65 additions and 2 deletions

View File

@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const load = useCallback(async () => {
if (!batchId) return;
@ -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 = () => {
<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>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
<Typography variant="h6">Marking queue</Typography>
<Button
size="small"
variant="outlined"
startIcon={<UploadFileIcon fontSize="small" />}
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? 'Uploading…' : 'Upload scans'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
multiple
hidden
onChange={(e) => { void onScanFiles(e.target.files); }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />

View File

@ -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<StudentSubmission> {
const headers = await authHeaders();
const form = new FormData();
form.append('file', file);
form.append('matching_method', opts.matchingMethod ?? 'ordered');
if (opts.studentId) form.append('student_id', opts.studentId);
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
return res.data;
},
};
export default examRepository;