Merge P4 digital-text viewer
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
dbab592ce8
@ -1,7 +1,8 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Dialog, DialogContent, DialogTitle, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||
import DescriptionIcon from '@mui/icons-material/Description'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
||||
@ -17,7 +18,7 @@ import axios from 'axios'
|
||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||
import { logger } from '../../../debugConfig'
|
||||
import { examRepository } from '../../../services/exam/examRepository'
|
||||
import type { AutoMapJobStatus, ExamTemplateDetail } from '../../../types/exam.types'
|
||||
import type { AutoMapJobStatus, DigitalTextResponse, ExamTemplateDetail } from '../../../types/exam.types'
|
||||
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||
@ -224,8 +225,25 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
||||
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
||||
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||
const [digitalText, setDigitalText] = useState<DigitalTextResponse | null>(null)
|
||||
const [digitalOpen, setDigitalOpen] = useState(false)
|
||||
const [digitalBusy, setDigitalBusy] = useState(false)
|
||||
const autoMapPollRef = useRef<number | null>(null)
|
||||
|
||||
const openDigitalText = useCallback(async () => {
|
||||
if (!template) return
|
||||
setDigitalBusy(true); setDigitalOpen(true)
|
||||
try {
|
||||
setDigitalText(await examRepository.getDigitalText(template.id))
|
||||
} catch (e) {
|
||||
setDigitalText(null)
|
||||
setError(e instanceof Error ? e.message : 'No digital text yet — run auto-map first.')
|
||||
setDigitalOpen(false)
|
||||
} finally {
|
||||
setDigitalBusy(false)
|
||||
}
|
||||
}, [template])
|
||||
|
||||
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
|
||||
|
||||
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
||||
@ -439,6 +457,11 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
|
||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
|
||||
{(() => {
|
||||
const a = (template as (ExamTemplateDetail & { extraction_meta?: { audit?: { status?: string; cover_total?: number; detected_total?: number } } }) | null)?.extraction_meta?.audit
|
||||
return a?.status ? <Tooltip title="Extraction cover-total reconciliation (detected vs printed maximum)"><Chip size="small" color={a.status === 'ok' ? 'success' : 'warning'} label={`Marks ${a.detected_total ?? '?'}/${a.cover_total ?? '?'}`} /></Tooltip> : null
|
||||
})()}
|
||||
<Button size="small" variant="outlined" startIcon={digitalBusy ? <CircularProgress size={14} /> : <DescriptionIcon fontSize="small" />} onClick={openDigitalText} disabled={digitalBusy || !template}>Digital text</Button>
|
||||
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
|
||||
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||
</Paper>
|
||||
@ -552,6 +575,32 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
||||
<Dialog open={digitalOpen} onClose={() => setDigitalOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DescriptionIcon fontSize="small" />
|
||||
Digital text
|
||||
{digitalText && <Typography component="span" variant="body2" color="text.secondary">— {digitalText.title} · {digitalText.n_questions}Q · {digitalText.total_marks} marks</Typography>}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{digitalText && (
|
||||
<Tooltip title="Download markdown">
|
||||
<IconButton size="small" onClick={() => {
|
||||
const blob = new Blob([digitalText.markdown], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob); const a = document.createElement('a')
|
||||
a.href = url; a.download = `${digitalText.slug}.md`; a.click(); URL.revokeObjectURL(url)
|
||||
}}><SaveIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{digitalBusy ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||
) : digitalText ? (
|
||||
<Box component="pre" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13, m: 0 }}>{digitalText.markdown}</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">No digital text available.</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
AutoMapResponse,
|
||||
BankResponse,
|
||||
CorpusResponse,
|
||||
DigitalTextResponse,
|
||||
BatchQueueResponse,
|
||||
BatchResultsResponse,
|
||||
CreateBatchPayload,
|
||||
@ -164,6 +165,12 @@ export const examRepository = {
|
||||
return res.data.templates ?? [];
|
||||
},
|
||||
|
||||
async getDigitalText(templateId: string): Promise<DigitalTextResponse> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<DigitalTextResponse>(`${EXAM_BASE}/templates/${templateId}/digital-text`, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
|
||||
@ -396,3 +396,13 @@ export interface CorpusResponse {
|
||||
totals: { specs: number; papers: number; sessions: number; QP: number; MS: number; ER: number };
|
||||
boards: CorpusBoard[];
|
||||
}
|
||||
|
||||
// ── Digital-replica markdown (P4) ─────────────────────────────────────────────
|
||||
export interface DigitalTextResponse {
|
||||
slug: string;
|
||||
title: string;
|
||||
n_questions: number;
|
||||
total_marks: number;
|
||||
markdown: string;
|
||||
questions: { label: string; marks: number | null; markdown: string }[];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user