This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/components/ocr/BlockReviewPanel.tsx
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

459 lines
16 KiB
TypeScript

'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<string, { vocabulary: Array<{ english: string; german: string; example?: string }> }>
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<number, BlockReviewData>
className?: string
}
// Method colors for consistent display
const METHOD_COLORS: Record<string, { bg: string; border: string; text: string }> = {
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<string, string> = {
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 (
<div className={cn('p-4 text-center text-slate-500', className)}>
Keine Blöcke zum Überprüfen
</div>
)
}
return (
<div className={cn('flex flex-col h-full', className)}>
{/* Header with progress */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<span className="font-semibold text-slate-700">
Block {currentBlockNumber}
</span>
<span className="text-sm text-slate-500">
({currentIndex + 1} von {nonEmptyBlocks.length})
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-slate-500">{progress}%</span>
</div>
</div>
{/* Block status indicator */}
{currentReview && currentReview.status !== 'pending' && (
<div className={cn(
'px-4 py-2 text-sm font-medium',
currentReview.status === 'approved' && 'bg-green-100 text-green-700',
currentReview.status === 'corrected' && 'bg-blue-100 text-blue-700',
currentReview.status === 'skipped' && 'bg-slate-100 text-slate-600',
)}>
{currentReview.status === 'approved' && `✓ Freigegeben: "${currentReview.correctedText}"`}
{currentReview.status === 'corrected' && `✎ Korrigiert: "${currentReview.correctedText}"`}
{currentReview.status === 'skipped' && '○ Übersprungen'}
</div>
)}
{/* Cell info */}
<div className="px-4 py-2 bg-slate-50 border-b text-sm">
<span className="text-slate-500">Position: </span>
<span className="font-medium">Zeile {currentBlock.cell.row + 1}, Spalte {currentBlock.cell.col + 1}</span>
{currentBlock.cell.column_type && (
<>
<span className="text-slate-500 ml-3">Typ: </span>
<span className="font-medium capitalize">{currentBlock.cell.column_type}</span>
</>
)}
</div>
{/* Method results */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<h4 className="text-sm font-medium text-slate-600 mb-2">Erkannte Texte:</h4>
{blockMethodResults.map((result) => {
const colors = METHOD_COLORS[result.methodId] || {
bg: 'bg-slate-50',
border: 'border-slate-300',
text: 'text-slate-700'
}
return (
<div
key={result.methodId}
className={cn(
'p-3 rounded-lg border-2 transition-all',
colors.bg,
colors.border,
'hover:shadow-md cursor-pointer'
)}
onClick={() => handleApprove(result.methodId, result.text)}
>
<div className="flex items-center justify-between mb-1">
<span className={cn('text-xs font-semibold', colors.text)}>
{result.methodName}
</span>
{result.confidence !== undefined && result.confidence < 0.7 && (
<span className="text-xs text-orange-500">
{Math.round(result.confidence * 100)}%
</span>
)}
</div>
<div className="text-sm font-medium text-slate-900">
{result.text || <span className="text-slate-400 italic">(leer)</span>}
</div>
<div className="mt-2 text-xs text-slate-500">
Klicken zum Übernehmen
</div>
</div>
)
})}
{/* Manual correction */}
{showCorrection ? (
<div className="p-3 rounded-lg border-2 border-indigo-300 bg-indigo-50">
<label className="text-xs font-semibold text-indigo-700 block mb-2">
Manuelle Korrektur:
</label>
<input
type="text"
value={correctionText}
onChange={(e) => 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)
}}
/>
<div className="flex gap-2 mt-2">
<button
onClick={handleCorrect}
disabled={!correctionText.trim()}
className="flex-1 px-3 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Übernehmen
</button>
<button
onClick={() => setShowCorrection(false)}
className="px-3 py-1.5 bg-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-300"
>
Abbrechen
</button>
</div>
</div>
) : (
<button
onClick={() => setShowCorrection(true)}
className="w-full p-3 rounded-lg border-2 border-dashed border-slate-300 text-slate-500 text-sm hover:border-indigo-400 hover:text-indigo-600 transition-colors"
>
+ Manuell korrigieren
</button>
)}
</div>
{/* Navigation footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<button
onClick={goToPrevious}
disabled={currentIndex === 0}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurück
</button>
<button
onClick={handleSkip}
className="px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
>
Überspringen
</button>
<button
onClick={goToNext}
disabled={currentIndex === nonEmptyBlocks.length - 1}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
>
Weiter
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)
}
/**
* BlockReviewSummary Component
*
* Shows a summary of all reviewed blocks.
*/
interface BlockReviewSummaryProps {
reviewData: Record<number, BlockReviewData>
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 (
<div className={cn('p-4 space-y-4', className)}>
<h3 className="font-semibold text-slate-800">Überprüfungsübersicht</h3>
{/* Stats */}
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-green-50 rounded-lg text-center">
<div className="text-xl font-bold text-green-700">{stats.approved}</div>
<div className="text-xs text-green-600">Freigegeben</div>
</div>
<div className="p-2 bg-blue-50 rounded-lg text-center">
<div className="text-xl font-bold text-blue-700">{stats.corrected}</div>
<div className="text-xs text-blue-600">Korrigiert</div>
</div>
<div className="p-2 bg-slate-100 rounded-lg text-center">
<div className="text-xl font-bold text-slate-600">{stats.skipped}</div>
<div className="text-xs text-slate-500">Übersprungen</div>
</div>
<div className="p-2 bg-amber-50 rounded-lg text-center">
<div className="text-xl font-bold text-amber-600">{stats.pending}</div>
<div className="text-xs text-amber-500">Ausstehend</div>
</div>
</div>
{/* Block list */}
<div className="space-y-1 max-h-64 overflow-y-auto">
{Object.entries(reviewData)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([blockNum, data]) => (
<button
key={blockNum}
onClick={() => onBlockClick(Number(blockNum))}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
data.status === 'approved' && 'bg-green-50 hover:bg-green-100',
data.status === 'corrected' && 'bg-blue-50 hover:bg-blue-100',
data.status === 'skipped' && 'bg-slate-50 hover:bg-slate-100',
)}
>
<span className="font-medium text-slate-700">Block {blockNum}</span>
<span className={cn(
'text-xs',
data.status === 'approved' && 'text-green-600',
data.status === 'corrected' && 'text-blue-600',
data.status === 'skipped' && 'text-slate-500',
)}>
{data.status === 'approved' && '✓'}
{data.status === 'corrected' && '✎'}
{data.status === 'skipped' && '○'}
{' '}
{data.correctedText?.substring(0, 20)}
{data.correctedText && data.correctedText.length > 20 && '...'}
</span>
</button>
))}
</div>
</div>
)
}