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>
459 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|