Merge fx-5-scan-ingestion
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
This commit is contained in:
commit
b52f6c54ba
@ -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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { v5 as uuidv5 } from 'uuid';
|
import { v5 as uuidv5 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
@ -21,6 +21,7 @@ import {
|
|||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||||
@ -43,6 +44,8 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = 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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!batchId) return;
|
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?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
||||||
[template],
|
[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 selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
||||||
|
|
||||||
const saveSelected = async () => {
|
const saveSelected = async () => {
|
||||||
@ -154,7 +179,29 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||||
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||||
<CardContent>
|
<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>
|
<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.total ?? 0} total`} />
|
||||||
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import type {
|
|||||||
Neo4jSyncResult,
|
Neo4jSyncResult,
|
||||||
PatchQuestionPayload,
|
PatchQuestionPayload,
|
||||||
SpecPoint,
|
SpecPoint,
|
||||||
|
StudentSubmission,
|
||||||
TemplateReplacePayload,
|
TemplateReplacePayload,
|
||||||
UpdateTemplateMetaPayload,
|
UpdateTemplateMetaPayload,
|
||||||
} from '../../types/exam.types';
|
} from '../../types/exam.types';
|
||||||
@ -298,6 +299,21 @@ export const examRepository = {
|
|||||||
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async uploadScan(
|
||||||
|
batchId: string,
|
||||||
|
file: File,
|
||||||
|
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
|
||||||
|
): Promise<StudentSubmission> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('matching_method', opts.matchingMethod ?? 'ordered');
|
||||||
|
if (opts.studentId) form.append('student_id', opts.studentId);
|
||||||
|
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
|
||||||
|
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default examRepository;
|
export default examRepository;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user