|
|
@@ -1,7 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, 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 { 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 ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
|
|
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
|
|
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
|
|
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
|
|
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
|
|
@@ -17,7 +18,7 @@ import axios from 'axios'
|
|
|
|
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
|
|
|
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
|
|
|
import { logger } from '../../../debugConfig'
|
|
|
|
import { logger } from '../../../debugConfig'
|
|
|
|
import { examRepository } from '../../../services/exam/examRepository'
|
|
|
|
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 { 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 { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
|
|
|
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
|
|
|
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
|
|
@@ -224,8 +225,25 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|
|
|
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
|
|
|
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
|
|
|
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
|
|
|
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
|
|
|
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
|
|
|
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 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 refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
|
|
|
|
|
|
|
|
|
|
|
|
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
|
|
|
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={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'} />
|
|
|
|
<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)} />}
|
|
|
|
{(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="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>
|
|
|
|
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
|
|
|
</Paper>
|
|
|
|
</Paper>
|
|
|
@@ -552,6 +575,32 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|
|
|
</Box>
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></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>
|
|
|
|
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
|
|
|
</Box>
|
|
|
|
</Box>
|
|
|
|
)
|
|
|
|
)
|
|
|
|