'use client' import { useCallback, useEffect, useState, useRef } from 'react' import { useSearchParams } from 'next/navigation' import { PagePurpose } from '@/components/common/PagePurpose' import { PipelineStepper } from '@/components/ocr-pipeline/PipelineStepper' import { StepOrientation } from '@/components/ocr-pipeline/StepOrientation' import { StepDeskew } from '@/components/ocr-pipeline/StepDeskew' import { StepDewarp } from '@/components/ocr-pipeline/StepDewarp' import { StepCrop } from '@/components/ocr-pipeline/StepCrop' import { StepStructureDetection } from '@/components/ocr-pipeline/StepStructureDetection' import { StepRowDetection } from '@/components/ocr-pipeline/StepRowDetection' import { StepWordRecognition } from '@/components/ocr-pipeline/StepWordRecognition' import { OverlayReconstruction } from '@/components/ocr-overlay/OverlayReconstruction' import { PaddleDirectStep } from '@/components/ocr-overlay/PaddleDirectStep' import { GridEditor } from '@/components/grid-editor/GridEditor' import { StepGridReview } from '@/components/ocr-pipeline/StepGridReview' import { BoxSessionTabs } from '@/components/ocr-pipeline/BoxSessionTabs' import { OVERLAY_PIPELINE_STEPS, PADDLE_DIRECT_STEPS, KOMBI_STEPS, DOCUMENT_CATEGORIES, dbStepToOverlayUi, type PipelineStep, type SessionListItem, type DocumentCategory } from './types' import type { SubSession } from '../ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' export default function OcrOverlayPage() { const [mode, setMode] = useState<'pipeline' | 'paddle-direct' | 'kombi'>('pipeline') const [currentStep, setCurrentStep] = useState(0) const [sessionId, setSessionId] = useState(null) const [sessionName, setSessionName] = useState('') const [sessions, setSessions] = useState([]) const [loadingSessions, setLoadingSessions] = useState(true) const [editingName, setEditingName] = useState(null) const [editNameValue, setEditNameValue] = useState('') const [editingCategory, setEditingCategory] = useState(null) const [activeCategory, setActiveCategory] = useState(undefined) const [editingActiveCategory, setEditingActiveCategory] = useState(false) const [subSessions, setSubSessions] = useState([]) const [parentSessionId, setParentSessionId] = useState(null) const [isGroundTruth, setIsGroundTruth] = useState(false) const [gtSaving, setGtSaving] = useState(false) const [gtMessage, setGtMessage] = useState('') const [steps, setSteps] = useState( OVERLAY_PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending', })), ) const searchParams = useSearchParams() const deepLinkHandled = useRef(false) const gridSaveRef = useRef<(() => Promise) | null>(null) useEffect(() => { loadSessions() }, []) const loadSessions = async () => { setLoadingSessions(true) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`) if (res.ok) { const data = await res.json() // Filter to only show top-level sessions (no sub-sessions) setSessions((data.sessions || []).filter((s: SessionListItem) => !s.parent_session_id)) } } catch (e) { console.error('Failed to load sessions:', e) } finally { setLoadingSessions(false) } } const openSession = useCallback(async (sid: string, keepSubSessions?: boolean) => { try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`) if (!res.ok) return const data = await res.json() setSessionId(sid) setSessionName(data.name || data.filename || '') setActiveCategory(data.document_category || undefined) setIsGroundTruth(!!data.ground_truth?.build_grid_reference) setGtMessage('') // Sub-session handling if (data.sub_sessions && data.sub_sessions.length > 0) { setSubSessions(data.sub_sessions) setParentSessionId(sid) } else if (data.parent_session_id) { setParentSessionId(data.parent_session_id) } else if (!keepSubSessions) { setSubSessions([]) setParentSessionId(null) } const isSubSession = !!data.parent_session_id // Mode detection for root sessions with word_result const ocrEngine = data.word_result?.ocr_engine const isPaddleDirect = ocrEngine === 'paddle_direct' const isKombi = ocrEngine === 'kombi' || ocrEngine === 'rapid_kombi' let activeMode = mode // keep current mode for sub-sessions if (!isSubSession && (isPaddleDirect || isKombi)) { activeMode = isKombi ? 'kombi' : 'paddle-direct' setMode(activeMode) } else if (!isSubSession && !ocrEngine) { // Unprocessed root session: keep the user's selected mode activeMode = mode } const baseSteps = activeMode === 'kombi' ? KOMBI_STEPS : activeMode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS // Determine UI step let uiStep: number const skipIds: string[] = [] if (!isSubSession && (isPaddleDirect || isKombi)) { const hasGrid = isKombi && data.grid_editor_result const hasStructure = isKombi && data.structure_result uiStep = hasGrid ? 6 : hasStructure ? 6 : data.word_result ? 5 : 4 if (isPaddleDirect) uiStep = data.word_result ? 4 : 4 } else { const dbStep = data.current_step || 1 if (dbStep <= 2) uiStep = 0 else if (dbStep === 3) uiStep = 1 else if (dbStep === 4) uiStep = 2 else if (dbStep === 5) uiStep = 3 else uiStep = 4 // Sub-session skip logic if (isSubSession) { if (dbStep >= 5) { skipIds.push('orientation', 'deskew', 'dewarp', 'crop') if (uiStep < 4) uiStep = 4 } else if (dbStep >= 2) { skipIds.push('orientation') if (uiStep < 1) uiStep = 1 // advance past skipped orientation to deskew } } } setSteps( baseSteps.map((s, i) => ({ ...s, status: skipIds.includes(s.id) ? 'skipped' : i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending', })), ) setCurrentStep(uiStep) } catch (e) { console.error('Failed to open session:', e) } }, [mode]) // Handle deep-link: ?session=xxx&mode=kombi (from GT Queue page) useEffect(() => { if (deepLinkHandled.current) return const urlSession = searchParams.get('session') const urlMode = searchParams.get('mode') if (urlSession) { deepLinkHandled.current = true if (urlMode === 'kombi' || urlMode === 'paddle-direct') { setMode(urlMode) const baseSteps = urlMode === 'kombi' ? KOMBI_STEPS : PADDLE_DIRECT_STEPS setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' }))) } openSession(urlSession) } }, [searchParams, openSession]) const deleteSession = useCallback(async (sid: string) => { try { await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' }) setSessions((prev) => prev.filter((s) => s.id !== sid)) if (sessionId === sid) { setSessionId(null) setCurrentStep(0) setSubSessions([]) setParentSessionId(null) const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' }))) } } catch (e) { console.error('Failed to delete session:', e) } }, [sessionId, mode]) const renameSession = useCallback(async (sid: string, newName: string) => { try { await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }), }) setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, name: newName } : s))) if (sessionId === sid) setSessionName(newName) } catch (e) { console.error('Failed to rename session:', e) } setEditingName(null) }, [sessionId]) const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => { try { await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_category: category }), }) setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, document_category: category } : s))) if (sessionId === sid) setActiveCategory(category) } catch (e) { console.error('Failed to update category:', e) } setEditingCategory(null) }, [sessionId]) const handleStepClick = (index: number) => { if (index <= currentStep || steps[index].status === 'completed') { setCurrentStep(index) } } const goToStep = (step: number) => { setCurrentStep(step) setSteps((prev) => prev.map((s, i) => ({ ...s, status: i < step ? 'completed' : i === step ? 'active' : 'pending', })), ) } const handleNext = () => { if (currentStep >= steps.length - 1) { // Sub-session completed — switch back to parent if (parentSessionId && sessionId !== parentSessionId) { setSubSessions((prev) => prev.map((s) => s.id === sessionId ? { ...s, status: 'completed', current_step: 10 } : s) ) handleSessionChange(parentSessionId) return } // Last step completed — return to session list const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' }))) setCurrentStep(0) setSessionId(null) setSubSessions([]) setParentSessionId(null) loadSessions() return } const nextStep = currentStep + 1 setSteps((prev) => prev.map((s, i) => { if (i === currentStep) return { ...s, status: 'completed' } if (i === nextStep) return { ...s, status: 'active' } return s }), ) setCurrentStep(nextStep) } const handleOrientationComplete = async (sid: string) => { setSessionId(sid) loadSessions() // Check for page-split sub-sessions directly from API try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`) if (res.ok) { const data = await res.json() if (data.sub_sessions?.length > 0) { const subs: SubSession[] = data.sub_sessions.map((s: SubSession) => ({ id: s.id, name: s.name, box_index: s.box_index, current_step: s.current_step, })) setSubSessions(subs) setParentSessionId(sid) openSession(subs[0].id, true) return } } } catch (e) { console.error('Failed to check for sub-sessions:', e) } handleNext() } const handleBoxSessionsCreated = useCallback((subs: SubSession[]) => { setSubSessions(subs) if (sessionId) setParentSessionId(sessionId) }, [sessionId]) const handleSessionChange = useCallback((newSessionId: string) => { openSession(newSessionId, true) }, [openSession]) const handleNewSession = () => { setSessionId(null) setSessionName('') setCurrentStep(0) setSubSessions([]) setParentSessionId(null) const baseSteps = mode === 'kombi' ? KOMBI_STEPS : mode === 'paddle-direct' ? PADDLE_DIRECT_STEPS : OVERLAY_PIPELINE_STEPS setSteps(baseSteps.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' }))) } const stepNames: Record = { 1: 'Orientierung', 2: 'Begradigung', 3: 'Entzerrung', 4: 'Zuschneiden', 5: 'Zeilen', 6: 'Woerter', 7: 'Overlay', } const reprocessFromStep = useCallback(async (uiStep: number) => { if (!sessionId) return // Map overlay UI step to DB step const dbStepMap: Record = { 0: 2, 1: 3, 2: 4, 3: 5, 4: 7, 5: 8, 6: 9 } const dbStep = dbStepMap[uiStep] || uiStep + 1 if (!confirm(`Ab Schritt ${uiStep + 1} (${stepNames[uiStep + 1] || '?'}) neu verarbeiten?`)) return try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reprocess`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_step: dbStep }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) console.error('Reprocess failed:', data.detail || res.status) return } goToStep(uiStep) } catch (e) { console.error('Reprocess error:', e) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, goToStep]) const handleMarkGroundTruth = async () => { if (!sessionId) return setGtSaving(true) setGtMessage('') try { // Auto-save grid editor before marking GT (so DB has latest edits) if (gridSaveRef.current) { await gridSaveRef.current() } const resp = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=${mode}`, { method: 'POST' } ) if (!resp.ok) { const body = await resp.text().catch(() => '') throw new Error(`Ground Truth fehlgeschlagen (${resp.status}): ${body}`) } const data = await resp.json() setIsGroundTruth(true) setGtMessage(`Ground Truth gespeichert (${data.cells_saved} Zellen)`) setTimeout(() => setGtMessage(''), 5000) } catch (e) { setGtMessage(e instanceof Error ? e.message : String(e)) } finally { setGtSaving(false) } } const isLastStep = currentStep === steps.length - 1 const showGtButton = isLastStep && sessionId != null const renderStep = () => { if (mode === 'paddle-direct' || mode === 'kombi') { switch (currentStep) { case 0: return case 1: return case 2: return case 3: return case 4: if (mode === 'kombi') { return ( ) } return case 5: return mode === 'kombi' ? ( ) : null case 6: return mode === 'kombi' ? ( ) : null default: return null } } switch (currentStep) { case 0: return case 1: return case 2: return case 3: return case 4: return case 5: return case 6: return default: return null } } return (
{/* Session List */}

Sessions ({sessions.length})

{loadingSessions ? (
Lade Sessions...
) : sessions.length === 0 ? (
Noch keine Sessions vorhanden.
) : (
{sessions.map((s) => { const catInfo = DOCUMENT_CATEGORIES.find(c => c.value === s.document_category) return (
{/* Thumbnail */}
openSession(s.id)} > {/* eslint-disable-next-line @next/next/no-img-element */} { (e.target as HTMLImageElement).style.display = 'none' }} />
{/* Info */}
openSession(s.id)}> {editingName === s.id ? ( setEditNameValue(e.target.value)} onBlur={() => renameSession(s.id, editNameValue)} onKeyDown={(e) => { if (e.key === 'Enter') renameSession(s.id, editNameValue) if (e.key === 'Escape') setEditingName(null) }} onClick={(e) => e.stopPropagation()} className="w-full px-1 py-0.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600" /> ) : (
{s.name || s.filename}
)}
{new Date(s.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}
{/* Category Badge */}
e.stopPropagation()}>
{/* Actions */}
{/* Category dropdown */} {editingCategory === s.id && (
e.stopPropagation()} > {DOCUMENT_CATEGORIES.map((cat) => ( ))}
)}
) })}
)}
{/* Active session info + category picker */} {sessionId && sessionName && (
Aktive Session: {sessionName} {isGroundTruth && ( GT )} {editingActiveCategory && (
{DOCUMENT_CATEGORIES.map((cat) => ( ))}
)}
)} {/* Mode Toggle */}
{subSessions.length > 0 && parentSessionId && sessionId && ( )}
{renderStep()}
{/* Ground Truth button bar — visible on last step */} {showGtButton && (
{gtMessage && ( {gtMessage} )}
)}
) }