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>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,458 @@
'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>
)
}

View File

@@ -0,0 +1,294 @@
'use client'
/**
* CellCorrectionDialog Component
*
* Modal dialog for manually correcting OCR text in problematic or recognized cells.
* Shows cropped image of the cell for reference and allows text input.
*/
import { useState, useEffect } from 'react'
import type { GridCell } from './GridOverlay'
interface CellCorrectionDialogProps {
cell: GridCell
columnType: 'english' | 'german' | 'example' | 'unknown'
sessionId: string
pageNumber: number
onSave: (text: string) => void
onRetryOCR?: () => void
onClose: () => void
}
export function CellCorrectionDialog({
cell,
columnType,
sessionId,
pageNumber,
onSave,
onRetryOCR,
onClose,
}: CellCorrectionDialogProps) {
const [text, setText] = useState(cell.text || '')
const [loading, setLoading] = useState(false)
const [retrying, setRetrying] = useState(false)
const [cropUrl, setCropUrl] = useState<string | null>(null)
const KLAUSUR_API = '/klausur-api'
// Load cell crop image
useEffect(() => {
const loadCrop = async () => {
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell-crop/${pageNumber}/${cell.row}/${cell.col}`
)
if (res.ok) {
const blob = await res.blob()
setCropUrl(URL.createObjectURL(blob))
}
} catch (e) {
console.error('Failed to load cell crop:', e)
}
}
loadCrop()
return () => {
if (cropUrl) {
URL.revokeObjectURL(cropUrl)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, pageNumber, cell.row, cell.col])
const handleSave = async () => {
if (!text.trim()) return
setLoading(true)
try {
const formData = new FormData()
formData.append('text', text)
const res = await fetch(
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell/${cell.row}/${cell.col}`,
{
method: 'PUT',
body: formData,
}
)
if (res.ok) {
onSave(text)
onClose()
} else {
console.error('Failed to save cell:', await res.text())
}
} catch (e) {
console.error('Save failed:', e)
} finally {
setLoading(false)
}
}
const handleRetryOCR = async () => {
if (!onRetryOCR) return
setRetrying(true)
try {
await onRetryOCR()
} finally {
setRetrying(false)
}
}
const getColumnLabel = () => {
switch (columnType) {
case 'english':
return 'Englisch'
case 'german':
return 'Deutsch'
case 'example':
return 'Beispielsatz'
default:
return 'Text'
}
}
const getPlaceholder = () => {
switch (columnType) {
case 'english':
return 'Englisches Wort eingeben...'
case 'german':
return 'Deutsche Ubersetzung eingeben...'
case 'example':
return 'Beispielsatz eingeben...'
default:
return 'Text eingeben...'
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative w-full max-w-lg bg-white rounded-xl shadow-xl overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-900">
{cell.status === 'problematic' ? 'Nicht erkannter Bereich' : 'Text bearbeiten'}
</h3>
<p className="text-sm text-slate-500">
Zeile {cell.row + 1}, Spalte {cell.col + 1} ({getColumnLabel()})
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Cell image preview */}
<div className="border rounded-lg p-3 bg-slate-50">
<p className="text-xs text-slate-500 mb-2 font-medium">Originalbild:</p>
{cropUrl ? (
<img
src={cropUrl}
alt="Zellinhalt"
className="max-h-32 mx-auto rounded border border-slate-200 bg-white"
/>
) : (
<div className="h-20 flex items-center justify-center text-slate-400 text-sm">
Lade Vorschau...
</div>
)}
</div>
{/* Status indicator */}
{cell.status === 'problematic' && (
<div className="flex items-center gap-2 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<svg className="w-5 h-5 text-orange-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm text-orange-700">
Diese Zelle konnte nicht automatisch erkannt werden.
</span>
</div>
)}
{/* Current recognized text */}
{cell.status === 'recognized' && cell.text && (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
<p className="text-xs text-green-600 font-medium mb-1">Erkannter Text:</p>
<p className="text-sm text-green-800">{cell.text}</p>
{cell.confidence < 1 && (
<p className="text-xs text-green-600 mt-1">
Konfidenz: {Math.round(cell.confidence * 100)}%
</p>
)}
</div>
)}
{/* Text input */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{cell.status === 'problematic' ? 'Text eingeben' : 'Text korrigieren'}
</label>
{columnType === 'example' ? (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={getPlaceholder()}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
/>
) : (
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={getPlaceholder()}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSave()
}
}}
/>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-between bg-slate-50">
<div>
{onRetryOCR && cell.status === 'problematic' && (
<button
onClick={handleRetryOCR}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center gap-2"
>
{retrying ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Erneut versuchen...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
OCR erneut versuchen
</>
)}
</button>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={loading || !text.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Speichern...
</>
) : (
'Speichern'
)}
</button>
</div>
</div>
</div>
</div>
)
}
export default CellCorrectionDialog

View File

@@ -0,0 +1,357 @@
'use client'
/**
* GridOverlay Component
*
* SVG overlay for displaying detected OCR grid structure on document images.
* Shows recognized (green), problematic (orange), manual (blue), and empty (transparent) cells.
* Supports click-to-edit for problematic cells.
*/
import { useCallback } from 'react'
import { cn } from '@/lib/utils'
export type CellStatus = 'empty' | 'recognized' | 'problematic' | 'manual'
export interface GridCell {
row: number
col: number
x: number // X position as percentage (0-100)
y: number // Y position as percentage (0-100)
width: number // Width as percentage (0-100)
height: number // Height as percentage (0-100)
text: string
confidence: number
status: CellStatus
column_type?: 'english' | 'german' | 'example' | 'unknown'
}
export interface GridData {
rows: number
columns: number
cells: GridCell[][]
column_types: string[]
column_boundaries: number[]
row_boundaries: number[]
deskew_angle: number
stats: {
recognized: number
problematic: number
empty: number
manual?: number
total: number
coverage: number
}
}
interface GridOverlayProps {
grid: GridData
imageUrl?: string
onCellClick?: (cell: GridCell) => void
selectedCell?: GridCell | null
showEmpty?: boolean
showLabels?: boolean
showNumbers?: boolean // Show block numbers in cells
highlightedBlockNumber?: number | null // Highlight specific block
className?: string
}
// Status colors
const STATUS_COLORS = {
recognized: {
fill: 'rgba(34, 197, 94, 0.2)', // green-500 with opacity
stroke: '#22c55e', // green-500
hoverFill: 'rgba(34, 197, 94, 0.3)',
},
problematic: {
fill: 'rgba(249, 115, 22, 0.3)', // orange-500 with opacity
stroke: '#f97316', // orange-500
hoverFill: 'rgba(249, 115, 22, 0.4)',
},
manual: {
fill: 'rgba(59, 130, 246, 0.2)', // blue-500 with opacity
stroke: '#3b82f6', // blue-500
hoverFill: 'rgba(59, 130, 246, 0.3)',
},
empty: {
fill: 'transparent',
stroke: 'rgba(148, 163, 184, 0.3)', // slate-400 with opacity
hoverFill: 'rgba(148, 163, 184, 0.1)',
},
}
// Helper to calculate block number (1-indexed, row-by-row)
export function getCellBlockNumber(cell: GridCell, grid: GridData): number {
return cell.row * grid.columns + cell.col + 1
}
export function GridOverlay({
grid,
imageUrl,
onCellClick,
selectedCell,
showEmpty = false,
showLabels = true,
showNumbers = false,
highlightedBlockNumber,
className,
}: GridOverlayProps) {
const handleCellClick = useCallback(
(cell: GridCell) => {
if (onCellClick && cell.status !== 'empty') {
onCellClick(cell)
}
},
[onCellClick]
)
const flatCells = grid.cells.flat()
return (
<div className={cn('relative', className)}>
{/* Background image */}
{imageUrl && (
<img
src={imageUrl}
alt="Document"
className="w-full h-auto"
/>
)}
{/* SVG overlay */}
<svg
className="absolute inset-0 w-full h-full"
style={{ pointerEvents: 'none' }}
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* Column type labels */}
{showLabels && grid.column_types.length > 0 && (
<g>
{grid.column_types.map((type, idx) => {
const x = grid.column_boundaries[idx]
const width = grid.column_boundaries[idx + 1] - x
const label = type === 'english' ? 'EN' : type === 'german' ? 'DE' : type === 'example' ? 'Ex' : '?'
return (
<text
key={`col-label-${idx}`}
x={x + width / 2}
y={1.5}
textAnchor="middle"
fontSize="1.5"
fill="#64748b"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{label}
</text>
)
})}
</g>
)}
{/* Grid cells */}
{flatCells.map((cell) => {
const colors = STATUS_COLORS[cell.status]
const isSelected = selectedCell?.row === cell.row && selectedCell?.col === cell.col
const isClickable = cell.status !== 'empty' && onCellClick
const blockNumber = getCellBlockNumber(cell, grid)
const isHighlighted = highlightedBlockNumber === blockNumber
// Skip empty cells if not showing them
if (!showEmpty && cell.status === 'empty') {
return null
}
return (
<g
key={`cell-${cell.row}-${cell.col}`}
style={{ pointerEvents: isClickable ? 'auto' : 'none' }}
onClick={() => handleCellClick(cell)}
className={isClickable ? 'cursor-pointer' : ''}
>
{/* Cell rectangle */}
<rect
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill={isHighlighted ? 'rgba(99, 102, 241, 0.3)' : colors.fill}
stroke={isSelected || isHighlighted ? '#4f46e5' : colors.stroke}
strokeWidth={isSelected || isHighlighted ? 0.3 : 0.15}
rx={0.2}
className={cn(
'transition-all duration-150',
isClickable && 'hover:fill-opacity-40'
)}
/>
{/* Block number badge */}
{showNumbers && cell.status !== 'empty' && (
<>
<rect
x={cell.x + 0.3}
y={cell.y + 0.3}
width={2.5}
height={1.8}
fill={isHighlighted ? '#4f46e5' : '#374151'}
rx={0.3}
/>
<text
x={cell.x + 1.55}
y={cell.y + 1.5}
textAnchor="middle"
fontSize="1.2"
fill="white"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{blockNumber}
</text>
</>
)}
{/* Status indicator dot (only when not showing numbers) */}
{!showNumbers && cell.status !== 'empty' && (
<circle
cx={cell.x + 0.8}
cy={cell.y + 0.8}
r={0.5}
fill={colors.stroke}
stroke="white"
strokeWidth={0.1}
/>
)}
{/* Confidence indicator (for recognized cells) */}
{cell.status === 'recognized' && cell.confidence < 0.7 && (
<text
x={cell.x + cell.width - 0.5}
y={cell.y + 1.2}
fontSize="0.8"
fill="#f97316"
style={{ pointerEvents: 'none' }}
>
!
</text>
)}
{/* Selection highlight */}
{isSelected && (
<rect
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill="none"
stroke="#4f46e5"
strokeWidth={0.4}
strokeDasharray="0.5,0.3"
rx={0.2}
/>
)}
</g>
)
})}
{/* Row boundaries (optional grid lines) */}
{grid.row_boundaries.map((y, idx) => (
<line
key={`row-line-${idx}`}
x1={0}
y1={y}
x2={100}
y2={y}
stroke="rgba(148, 163, 184, 0.2)"
strokeWidth={0.05}
style={{ pointerEvents: 'none' }}
/>
))}
{/* Column boundaries */}
{grid.column_boundaries.map((x, idx) => (
<line
key={`col-line-${idx}`}
x1={x}
y1={0}
x2={x}
y2={100}
stroke="rgba(148, 163, 184, 0.2)"
strokeWidth={0.05}
style={{ pointerEvents: 'none' }}
/>
))}
</svg>
</div>
)
}
/**
* GridStats Component
*
* Displays statistics about the grid detection results.
*/
interface GridStatsProps {
stats: GridData['stats']
deskewAngle?: number
className?: string
}
export function GridStats({ stats, deskewAngle, className }: GridStatsProps) {
const coveragePercent = Math.round(stats.coverage * 100)
return (
<div className={cn('flex flex-wrap gap-3', className)}>
<div className="px-3 py-1.5 bg-green-50 text-green-700 rounded-lg text-sm font-medium">
Erkannt: {stats.recognized}
</div>
{(stats.manual ?? 0) > 0 && (
<div className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium">
Manuell: {stats.manual}
</div>
)}
{stats.problematic > 0 && (
<div className="px-3 py-1.5 bg-orange-50 text-orange-700 rounded-lg text-sm font-medium">
Problematisch: {stats.problematic}
</div>
)}
<div className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-sm font-medium">
Leer: {stats.empty}
</div>
<div className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-sm font-medium">
Abdeckung: {coveragePercent}%
</div>
{deskewAngle !== undefined && deskewAngle !== 0 && (
<div className="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-medium">
Begradigt: {deskewAngle.toFixed(1)}
</div>
)}
</div>
)
}
/**
* Legend Component for GridOverlay
*/
export function GridLegend({ className }: { className?: string }) {
return (
<div className={cn('flex flex-wrap gap-4 text-sm', className)}>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-green-500 bg-green-500/20" />
<span className="text-slate-600">Erkannt</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-orange-500 bg-orange-500/30" />
<span className="text-slate-600">Problematisch</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-blue-500 bg-blue-500/20" />
<span className="text-slate-600">Manuell korrigiert</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-slate-300 bg-transparent" />
<span className="text-slate-600">Leer</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,390 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { BlockReviewPanel, BlockReviewSummary, type BlockReviewData } from '../BlockReviewPanel'
import type { GridData, GridCell } from '../GridOverlay'
// Mock grid data
const createMockGrid = (rows: number = 3, cols: number = 3): GridData => {
const cells: GridCell[][] = []
for (let r = 0; r < rows; r++) {
const row: GridCell[] = []
for (let c = 0; c < cols; c++) {
row.push({
row: r,
col: c,
x: (c / cols) * 100,
y: (r / rows) * 100,
width: 100 / cols,
height: 100 / rows,
text: r === 0 || c === 0 ? '' : `cell-${r}-${c}`,
confidence: 0.85,
status: r === 0 || c === 0 ? 'empty' : 'recognized',
column_type: c === 1 ? 'english' : c === 2 ? 'german' : 'unknown',
})
}
cells.push(row)
}
return {
rows,
columns: cols,
cells,
column_types: ['unknown', 'english', 'german'],
column_boundaries: [0, 33.33, 66.66, 100],
row_boundaries: [0, 33.33, 66.66, 100],
deskew_angle: 0,
stats: {
recognized: 4,
problematic: 0,
empty: 5,
total: 9,
coverage: 0.44,
},
}
}
const createMockMethodResults = () => ({
vision_llm: {
vocabulary: [
{ english: 'word1', german: 'Wort1' },
{ english: 'word2', german: 'Wort2' },
],
},
tesseract: {
vocabulary: [
{ english: 'word1', german: 'Wort1' },
{ english: 'word2', german: 'Wort2' },
],
},
})
describe('BlockReviewPanel', () => {
const mockOnBlockChange = vi.fn()
const mockOnApprove = vi.fn()
const mockOnCorrect = vi.fn()
const mockOnSkip = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the current block number', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('Block 5')).toBeInTheDocument()
})
it('should display progress percentage', () => {
const reviewData: Record<number, BlockReviewData> = {
5: {
blockNumber: 5,
cell: createMockGrid().cells[1][1],
methodResults: [],
status: 'approved',
correctedText: 'approved text',
},
}
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={reviewData}
/>
)
expect(screen.getByText('25%')).toBeInTheDocument()
})
it('should show cell position information', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('Position:')).toBeInTheDocument()
})
it('should display method results', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('Erkannte Texte:')).toBeInTheDocument()
})
it('should call onSkip when skip button is clicked', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
fireEvent.click(screen.getByText('Überspringen'))
expect(mockOnSkip).toHaveBeenCalledWith(5)
})
it('should show manual correction button', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('+ Manuell korrigieren')).toBeInTheDocument()
})
it('should show correction input when correction button is clicked', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
fireEvent.click(screen.getByText('+ Manuell korrigieren'))
expect(screen.getByPlaceholderText('Korrekten Text eingeben...')).toBeInTheDocument()
})
it('should call onCorrect when correction is submitted', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
fireEvent.click(screen.getByText('+ Manuell korrigieren'))
const input = screen.getByPlaceholderText('Korrekten Text eingeben...')
fireEvent.change(input, { target: { value: 'corrected text' } })
fireEvent.click(screen.getByText('Übernehmen'))
expect(mockOnCorrect).toHaveBeenCalledWith(5, 'corrected text')
})
it('should show approved status when block is approved', () => {
const reviewData: Record<number, BlockReviewData> = {
5: {
blockNumber: 5,
cell: createMockGrid().cells[1][1],
methodResults: [],
status: 'approved',
correctedText: 'approved text',
},
}
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={reviewData}
/>
)
expect(screen.getByText(/Freigegeben:/)).toBeInTheDocument()
})
it('should disable previous button on first block', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
const prevButton = screen.getByText('Zurück').closest('button')
expect(prevButton).toBeDisabled()
})
it('should show empty message when no blocks available', () => {
const emptyGrid: GridData = {
...createMockGrid(),
cells: [[{
row: 0,
col: 0,
x: 0,
y: 0,
width: 100,
height: 100,
text: '',
confidence: 0,
status: 'empty'
}]],
}
render(
<BlockReviewPanel
grid={emptyGrid}
methodResults={{}}
currentBlockNumber={1}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('Keine Blöcke zum Überprüfen')).toBeInTheDocument()
})
})
describe('BlockReviewSummary', () => {
const mockOnBlockClick = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should display summary statistics', () => {
const reviewData: Record<number, BlockReviewData> = {
1: { blockNumber: 1, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 'text1' },
2: { blockNumber: 2, cell: {} as GridCell, methodResults: [], status: 'corrected', correctedText: 'text2' },
3: { blockNumber: 3, cell: {} as GridCell, methodResults: [], status: 'skipped' },
}
render(
<BlockReviewSummary
reviewData={reviewData}
totalBlocks={5}
onBlockClick={mockOnBlockClick}
/>
)
expect(screen.getByText('Überprüfungsübersicht')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument() // approved count
expect(screen.getByText('Freigegeben')).toBeInTheDocument()
expect(screen.getByText('Korrigiert')).toBeInTheDocument()
expect(screen.getByText('Übersprungen')).toBeInTheDocument()
expect(screen.getByText('Ausstehend')).toBeInTheDocument()
})
it('should call onBlockClick when a block is clicked', () => {
const reviewData: Record<number, BlockReviewData> = {
5: { blockNumber: 5, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 'text' },
}
render(
<BlockReviewSummary
reviewData={reviewData}
totalBlocks={10}
onBlockClick={mockOnBlockClick}
/>
)
fireEvent.click(screen.getByText('Block 5'))
expect(mockOnBlockClick).toHaveBeenCalledWith(5)
})
it('should show correct counts for each status', () => {
const reviewData: Record<number, BlockReviewData> = {
1: { blockNumber: 1, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 't1' },
2: { blockNumber: 2, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 't2' },
3: { blockNumber: 3, cell: {} as GridCell, methodResults: [], status: 'corrected', correctedText: 't3' },
}
render(
<BlockReviewSummary
reviewData={reviewData}
totalBlocks={5}
onBlockClick={mockOnBlockClick}
/>
)
// 2 approved, 1 corrected, 0 skipped, 2 pending
const approvedCount = screen.getAllByText('2')[0]
expect(approvedCount).toBeInTheDocument()
})
it('should truncate long corrected text', () => {
const reviewData: Record<number, BlockReviewData> = {
1: {
blockNumber: 1,
cell: {} as GridCell,
methodResults: [],
status: 'approved',
correctedText: 'This is a very long text that should be truncated'
},
}
render(
<BlockReviewSummary
reviewData={reviewData}
totalBlocks={1}
onBlockClick={mockOnBlockClick}
/>
)
expect(screen.getByText(/This is a very long t\.\.\./)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { GridOverlay, GridStats, GridLegend, getCellBlockNumber } from '../GridOverlay'
import type { GridData, GridCell } from '../GridOverlay'
// Helper to create mock grid data
const createMockGrid = (rows: number = 3, cols: number = 3): GridData => {
const cells: GridCell[][] = []
for (let r = 0; r < rows; r++) {
const row: GridCell[] = []
for (let c = 0; c < cols; c++) {
row.push({
row: r,
col: c,
x: (c / cols) * 100,
y: (r / rows) * 100,
width: 100 / cols,
height: 100 / rows,
text: `cell-${r}-${c}`,
confidence: 0.85,
status: r === 0 ? 'empty' : c === 0 ? 'problematic' : 'recognized',
column_type: c === 1 ? 'english' : c === 2 ? 'german' : 'unknown',
})
}
cells.push(row)
}
return {
rows,
columns: cols,
cells,
column_types: ['unknown', 'english', 'german'],
column_boundaries: [0, 33.33, 66.66, 100],
row_boundaries: [0, 33.33, 66.66, 100],
deskew_angle: 0,
stats: {
recognized: 4,
problematic: 2,
empty: 3,
manual: 0,
total: 9,
coverage: 0.67,
},
}
}
describe('getCellBlockNumber', () => {
it('should return correct block number for first cell', () => {
const grid = createMockGrid(3, 4)
const cell: GridCell = { row: 0, col: 0, x: 0, y: 0, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
expect(getCellBlockNumber(cell, grid)).toBe(1)
})
it('should return correct block number for cell in second row', () => {
const grid = createMockGrid(3, 4)
const cell: GridCell = { row: 1, col: 0, x: 0, y: 33, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
expect(getCellBlockNumber(cell, grid)).toBe(5)
})
it('should return correct block number for last cell', () => {
const grid = createMockGrid(3, 4)
const cell: GridCell = { row: 2, col: 3, x: 75, y: 66, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
expect(getCellBlockNumber(cell, grid)).toBe(12)
})
it('should calculate correctly for different grid sizes', () => {
const grid = createMockGrid(5, 5)
const cell: GridCell = { row: 2, col: 3, x: 60, y: 40, width: 20, height: 20, text: '', confidence: 1, status: 'empty' }
expect(getCellBlockNumber(cell, grid)).toBe(14) // row 2 * 5 cols + col 3 + 1 = 14
})
})
describe('GridOverlay', () => {
it('should render without crashing', () => {
render(<GridOverlay grid={createMockGrid()} />)
expect(document.querySelector('svg')).toBeInTheDocument()
})
it('should render image when imageUrl is provided', () => {
render(<GridOverlay grid={createMockGrid()} imageUrl="https://example.com/image.jpg" />)
const img = screen.getByAltText('Document')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/image.jpg')
})
it('should call onCellClick when a non-empty cell is clicked', () => {
const mockOnClick = vi.fn()
const grid = createMockGrid()
render(<GridOverlay grid={grid} onCellClick={mockOnClick} />)
// Find a recognized cell (non-empty) and click it
const recognizedCells = document.querySelectorAll('rect')
// Click on a cell that should be clickable (recognized status)
recognizedCells.forEach(rect => {
if (rect.getAttribute('fill')?.includes('34, 197, 94')) {
fireEvent.click(rect)
}
})
// onCellClick should have been called for recognized cells
expect(mockOnClick).toHaveBeenCalled()
})
it('should not call onCellClick for empty cells', () => {
const mockOnClick = vi.fn()
const emptyGrid: GridData = {
...createMockGrid(),
cells: [[{ row: 0, col: 0, x: 0, y: 0, width: 100, height: 100, text: '', confidence: 0, status: 'empty' }]],
}
render(<GridOverlay grid={emptyGrid} onCellClick={mockOnClick} showEmpty />)
const cell = document.querySelector('rect')
if (cell) {
fireEvent.click(cell)
}
expect(mockOnClick).not.toHaveBeenCalled()
})
it('should show column labels when showLabels is true', () => {
render(<GridOverlay grid={createMockGrid()} showLabels />)
// Check for column type labels
const svgTexts = document.querySelectorAll('text')
const labels = Array.from(svgTexts).map(t => t.textContent)
expect(labels).toContain('EN')
expect(labels).toContain('DE')
})
it('should hide empty cells when showEmpty is false', () => {
const grid = createMockGrid()
const emptyCellCount = grid.cells.flat().filter(c => c.status === 'empty').length
const { container } = render(<GridOverlay grid={grid} showEmpty={false} />)
const allRects = container.querySelectorAll('g > rect')
// Should have fewer rects when empty cells are hidden
const totalCells = grid.rows * grid.columns
expect(allRects.length).toBeLessThan(totalCells * 2) // each cell can have multiple rects
})
it('should show block numbers when showNumbers is true', () => {
render(<GridOverlay grid={createMockGrid()} showNumbers />)
const svgTexts = document.querySelectorAll('text')
const numbers = Array.from(svgTexts).map(t => t.textContent).filter(t => /^\d+$/.test(t || ''))
expect(numbers.length).toBeGreaterThan(0)
})
it('should highlight a specific block when highlightedBlockNumber is set', () => {
const grid = createMockGrid()
render(<GridOverlay grid={grid} showNumbers highlightedBlockNumber={5} />)
// Check that there's a highlighted element (with indigo color)
const rects = document.querySelectorAll('rect')
const highlightedRect = Array.from(rects).find(
rect => rect.getAttribute('stroke') === '#4f46e5'
)
expect(highlightedRect).toBeTruthy()
})
it('should apply custom className', () => {
const { container } = render(<GridOverlay grid={createMockGrid()} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should render row and column boundary lines', () => {
render(<GridOverlay grid={createMockGrid()} />)
const lines = document.querySelectorAll('line')
expect(lines.length).toBeGreaterThan(0)
})
})
describe('GridStats', () => {
const mockStats = {
recognized: 10,
problematic: 3,
empty: 5,
manual: 2,
total: 20,
coverage: 0.75,
}
it('should display recognized count', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Erkannt: 10')).toBeInTheDocument()
})
it('should display problematic count when greater than 0', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Problematisch: 3')).toBeInTheDocument()
})
it('should not display problematic when count is 0', () => {
render(<GridStats stats={{ ...mockStats, problematic: 0 }} />)
expect(screen.queryByText(/Problematisch:/)).not.toBeInTheDocument()
})
it('should display manual count when greater than 0', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Manuell: 2')).toBeInTheDocument()
})
it('should not display manual when count is 0', () => {
render(<GridStats stats={{ ...mockStats, manual: 0 }} />)
expect(screen.queryByText(/Manuell:/)).not.toBeInTheDocument()
})
it('should display empty count', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Leer: 5')).toBeInTheDocument()
})
it('should display coverage percentage', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Abdeckung: 75%')).toBeInTheDocument()
})
it('should display deskew angle when provided and non-zero', () => {
render(<GridStats stats={mockStats} deskewAngle={2.5} />)
expect(screen.getByText('Begradigt: 2.5')).toBeInTheDocument()
})
it('should not display deskew angle when 0', () => {
render(<GridStats stats={mockStats} deskewAngle={0} />)
expect(screen.queryByText(/Begradigt:/)).not.toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<GridStats stats={mockStats} className="custom-stats" />)
expect(container.firstChild).toHaveClass('custom-stats')
})
})
describe('GridLegend', () => {
it('should display all status labels', () => {
render(<GridLegend />)
expect(screen.getByText('Erkannt')).toBeInTheDocument()
expect(screen.getByText('Problematisch')).toBeInTheDocument()
expect(screen.getByText('Manuell korrigiert')).toBeInTheDocument()
expect(screen.getByText('Leer')).toBeInTheDocument()
})
it('should render color indicators for each status', () => {
const { container } = render(<GridLegend />)
const colorIndicators = container.querySelectorAll('.w-4.h-4.rounded')
expect(colorIndicators.length).toBe(4)
})
it('should apply custom className', () => {
const { container } = render(<GridLegend className="custom-legend" />)
expect(container.firstChild).toHaveClass('custom-legend')
})
})

View File

@@ -0,0 +1,13 @@
/**
* OCR Components
*
* Components for OCR grid detection and visualization.
*/
export { GridOverlay, GridStats, GridLegend, getCellBlockNumber } from './GridOverlay'
export type { GridCell, GridData, CellStatus } from './GridOverlay'
export { CellCorrectionDialog } from './CellCorrectionDialog'
export { BlockReviewPanel, BlockReviewSummary } from './BlockReviewPanel'
export type { BlockStatus, MethodResult, BlockReviewData } from './BlockReviewPanel'