'use client' /** * BlockReviewPanel Component * * Provides a block-by-block review interface for OCR comparison results. * Shows what each OCR method (A, B, C, D) detected for each grid block. * Allows approving or correcting each block. */ import { useState, useCallback, useMemo } from 'react' import { cn } from '@/lib/utils' import type { GridData, GridCell } from './GridOverlay' import { getCellBlockNumber } from './GridOverlay' export type BlockStatus = 'pending' | 'approved' | 'corrected' | 'skipped' export interface MethodResult { methodId: string methodName: string text: string confidence?: number } export interface BlockReviewData { blockNumber: number cell: GridCell methodResults: MethodResult[] status: BlockStatus correctedText?: string approvedMethodId?: string } interface BlockReviewPanelProps { grid: GridData methodResults: Record }> currentBlockNumber: number onBlockChange: (blockNumber: number) => void onApprove: (blockNumber: number, methodId: string, text: string) => void onCorrect: (blockNumber: number, correctedText: string) => void onSkip: (blockNumber: number) => void reviewData: Record className?: string } // Method colors for consistent display const METHOD_COLORS: Record = { vision_llm: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' }, tesseract: { bg: 'bg-green-50', border: 'border-green-300', text: 'text-green-700' }, paddleocr: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' }, claude_vision: { bg: 'bg-amber-50', border: 'border-amber-300', text: 'text-amber-700' }, } const METHOD_LABELS: Record = { vision_llm: 'B: Vision LLM', tesseract: 'D: Tesseract', paddleocr: 'C: PaddleOCR', claude_vision: 'E: Claude Vision', } export function BlockReviewPanel({ grid, methodResults, currentBlockNumber, onBlockChange, onApprove, onCorrect, onSkip, reviewData, className, }: BlockReviewPanelProps) { const [correctionText, setCorrectionText] = useState('') const [showCorrection, setShowCorrection] = useState(false) // Get all non-empty blocks const nonEmptyBlocks = useMemo(() => { const blocks: { blockNumber: number; cell: GridCell }[] = [] grid.cells.flat().forEach((cell) => { if (cell.status !== 'empty') { blocks.push({ blockNumber: getCellBlockNumber(cell, grid), cell, }) } }) return blocks.sort((a, b) => a.blockNumber - b.blockNumber) }, [grid]) // Current block data const currentBlock = useMemo(() => { return nonEmptyBlocks.find((b) => b.blockNumber === currentBlockNumber) }, [nonEmptyBlocks, currentBlockNumber]) // Current block index for navigation const currentIndex = useMemo(() => { return nonEmptyBlocks.findIndex((b) => b.blockNumber === currentBlockNumber) }, [nonEmptyBlocks, currentBlockNumber]) // Find matching vocabulary for current cell from each method const blockMethodResults = useMemo(() => { if (!currentBlock) return [] const results: MethodResult[] = [] const cellRow = currentBlock.cell.row const cellCol = currentBlock.cell.col // For each method, try to find vocabulary that matches this cell position Object.entries(methodResults).forEach(([methodId, data]) => { if (!data.vocabulary || data.vocabulary.length === 0) return // Get the text from the grid cell itself (from grid detection) const cellText = currentBlock.cell.text // Try to match vocabulary entries // This is a simplified matching - in production you'd match by position const vocabIndex = cellRow * grid.columns + cellCol let matchedText = '' if (data.vocabulary[vocabIndex]) { const v = data.vocabulary[vocabIndex] matchedText = currentBlock.cell.column_type === 'english' ? v.english : currentBlock.cell.column_type === 'german' ? v.german : v.example || '' } results.push({ methodId, methodName: METHOD_LABELS[methodId] || methodId, text: matchedText || cellText || '(nicht erkannt)', confidence: currentBlock.cell.confidence, }) }) // Always include the grid detection result if (currentBlock.cell.text) { results.unshift({ methodId: 'grid_detection', methodName: 'Grid-OCR', text: currentBlock.cell.text, confidence: currentBlock.cell.confidence, }) } return results }, [currentBlock, methodResults, grid.columns]) // Navigation handlers const goToPrevious = useCallback(() => { if (currentIndex > 0) { onBlockChange(nonEmptyBlocks[currentIndex - 1].blockNumber) setShowCorrection(false) setCorrectionText('') } }, [currentIndex, nonEmptyBlocks, onBlockChange]) const goToNext = useCallback(() => { if (currentIndex < nonEmptyBlocks.length - 1) { onBlockChange(nonEmptyBlocks[currentIndex + 1].blockNumber) setShowCorrection(false) setCorrectionText('') } }, [currentIndex, nonEmptyBlocks, onBlockChange]) // Action handlers const handleApprove = useCallback((methodId: string, text: string) => { onApprove(currentBlockNumber, methodId, text) goToNext() }, [currentBlockNumber, onApprove, goToNext]) const handleCorrect = useCallback(() => { if (correctionText.trim()) { onCorrect(currentBlockNumber, correctionText.trim()) goToNext() } }, [currentBlockNumber, correctionText, onCorrect, goToNext]) const handleSkip = useCallback(() => { onSkip(currentBlockNumber) goToNext() }, [currentBlockNumber, onSkip, goToNext]) // Calculate progress const reviewedCount = Object.values(reviewData).filter( (r) => r.status !== 'pending' ).length const progress = nonEmptyBlocks.length > 0 ? Math.round((reviewedCount / nonEmptyBlocks.length) * 100) : 0 const currentReview = reviewData[currentBlockNumber] if (!currentBlock) { return (
Keine Blöcke zum Überprüfen
) } return (
{/* Header with progress */}
Block {currentBlockNumber} ({currentIndex + 1} von {nonEmptyBlocks.length})
{progress}%
{/* Block status indicator */} {currentReview && currentReview.status !== 'pending' && (
{currentReview.status === 'approved' && `✓ Freigegeben: "${currentReview.correctedText}"`} {currentReview.status === 'corrected' && `✎ Korrigiert: "${currentReview.correctedText}"`} {currentReview.status === 'skipped' && '○ Übersprungen'}
)} {/* Cell info */}
Position: Zeile {currentBlock.cell.row + 1}, Spalte {currentBlock.cell.col + 1} {currentBlock.cell.column_type && ( <> Typ: {currentBlock.cell.column_type} )}
{/* Method results */}

Erkannte Texte:

{blockMethodResults.map((result) => { const colors = METHOD_COLORS[result.methodId] || { bg: 'bg-slate-50', border: 'border-slate-300', text: 'text-slate-700' } return (
handleApprove(result.methodId, result.text)} >
{result.methodName} {result.confidence !== undefined && result.confidence < 0.7 && ( ⚠ {Math.round(result.confidence * 100)}% )}
{result.text || (leer)}
Klicken zum Übernehmen
) })} {/* Manual correction */} {showCorrection ? (
setCorrectionText(e.target.value)} placeholder="Korrekten Text eingeben..." className="w-full px-3 py-2 border border-indigo-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') handleCorrect() if (e.key === 'Escape') setShowCorrection(false) }} />
) : ( )}
{/* Navigation footer */}
) } /** * BlockReviewSummary Component * * Shows a summary of all reviewed blocks. */ interface BlockReviewSummaryProps { reviewData: Record totalBlocks: number onBlockClick: (blockNumber: number) => void className?: string } export function BlockReviewSummary({ reviewData, totalBlocks, onBlockClick, className, }: BlockReviewSummaryProps) { const stats = useMemo(() => { const values = Object.values(reviewData) return { approved: values.filter((r) => r.status === 'approved').length, corrected: values.filter((r) => r.status === 'corrected').length, skipped: values.filter((r) => r.status === 'skipped').length, pending: totalBlocks - values.length, } }, [reviewData, totalBlocks]) return (

Überprüfungsübersicht

{/* Stats */}
{stats.approved}
Freigegeben
{stats.corrected}
Korrigiert
{stats.skipped}
Übersprungen
{stats.pending}
Ausstehend
{/* Block list */}
{Object.entries(reviewData) .sort(([a], [b]) => Number(a) - Number(b)) .map(([blockNum, data]) => ( ))}
) }