Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 23s
CI / test-go-edu-search (push) Successful in 25s
CI / test-python-klausur (push) Failing after 1m56s
CI / test-python-agent-core (push) Successful in 14s
CI / test-nodejs-website (push) Successful in 15s
- Color bar (red/colored indicator) now only shows when word_boxes text still matches the cell text — editing the cell hides stale colors - New "Auto-Korrektur" button: detects dominant prefix+number patterns per column (e.g. p.70, p.71) and completes partial entries (.65 → p.65) — requires 3+ matching entries before correcting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* StepGridReview — Last step of the Kombi Pipeline
|
|
*
|
|
* Split view: original scan on the left, GridEditor on the right.
|
|
* Adds confidence stats, row-accept buttons, and integrates with
|
|
* the GT marking flow in the parent page.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
|
|
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
|
|
import type { GridZone, LayoutDividers } from '@/components/grid-editor/types'
|
|
import { GridToolbar } from '@/components/grid-editor/GridToolbar'
|
|
import { GridTable } from '@/components/grid-editor/GridTable'
|
|
import { ImageLayoutEditor } from '@/components/grid-editor/ImageLayoutEditor'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
interface StepGridReviewProps {
|
|
sessionId: string | null
|
|
onNext?: () => void
|
|
saveRef?: MutableRefObject<(() => Promise<void>) | null>
|
|
}
|
|
|
|
export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewProps) {
|
|
const {
|
|
grid,
|
|
loading,
|
|
saving,
|
|
error,
|
|
dirty,
|
|
selectedCell,
|
|
selectedCells,
|
|
setSelectedCell,
|
|
buildGrid,
|
|
loadGrid,
|
|
saveGrid,
|
|
updateCellText,
|
|
toggleColumnBold,
|
|
toggleRowHeader,
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
getAdjacentCell,
|
|
deleteColumn,
|
|
addColumn,
|
|
deleteRow,
|
|
addRow,
|
|
commitUndoPoint,
|
|
updateColumnDivider,
|
|
updateLayoutHorizontals,
|
|
splitColumnAt,
|
|
toggleCellSelection,
|
|
clearCellSelection,
|
|
toggleSelectedBold,
|
|
autoCorrectColumnPatterns,
|
|
} = useGridEditor(sessionId)
|
|
|
|
const [showImage, setShowImage] = useState(true)
|
|
const [zoom, setZoom] = useState(100)
|
|
const [acceptedRows, setAcceptedRows] = useState<Set<string>>(new Set())
|
|
|
|
// Expose save function to parent via ref (for GT marking auto-save)
|
|
useEffect(() => {
|
|
if (saveRef) {
|
|
saveRef.current = async () => {
|
|
if (dirty) await saveGrid()
|
|
}
|
|
return () => { saveRef.current = null }
|
|
}
|
|
}, [saveRef, dirty, saveGrid])
|
|
|
|
// Load grid on mount
|
|
useEffect(() => {
|
|
if (sessionId) loadGrid()
|
|
}, [sessionId, loadGrid])
|
|
|
|
// Reset accepted rows when session changes
|
|
useEffect(() => {
|
|
setAcceptedRows(new Set())
|
|
}, [sessionId])
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
undo()
|
|
} else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
|
|
e.preventDefault()
|
|
redo()
|
|
} else if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
e.preventDefault()
|
|
saveGrid()
|
|
} else if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
|
e.preventDefault()
|
|
if (selectedCells.size > 0) {
|
|
toggleSelectedBold()
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
clearCellSelection()
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [undo, redo, saveGrid, selectedCells, toggleSelectedBold, clearCellSelection])
|
|
|
|
const handleNavigate = useCallback(
|
|
(cellId: string, direction: 'up' | 'down' | 'left' | 'right') => {
|
|
const target = getAdjacentCell(cellId, direction)
|
|
if (target) {
|
|
setSelectedCell(target)
|
|
setTimeout(() => {
|
|
const el = document.getElementById(`cell-${target}`)
|
|
if (el) {
|
|
el.focus()
|
|
if (el instanceof HTMLInputElement) el.select()
|
|
}
|
|
}, 0)
|
|
}
|
|
},
|
|
[getAdjacentCell, setSelectedCell],
|
|
)
|
|
|
|
const acceptRow = (zoneIdx: number, rowIdx: number) => {
|
|
setAcceptedRows((prev) => {
|
|
const next = new Set(prev)
|
|
const key = `${zoneIdx}-${rowIdx}`
|
|
if (next.has(key)) next.delete(key)
|
|
else next.add(key)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const acceptAllRows = () => {
|
|
if (!grid) return
|
|
const all = new Set<string>()
|
|
for (const zone of grid.zones) {
|
|
for (const row of zone.rows) {
|
|
all.add(`${zone.zone_index}-${row.index}`)
|
|
}
|
|
}
|
|
setAcceptedRows(all)
|
|
}
|
|
|
|
// Confidence stats
|
|
const allCells = grid?.zones?.flatMap((z) => z.cells) || []
|
|
const lowConfCells = allCells.filter(
|
|
(c) => c.confidence > 0 && c.confidence < 60,
|
|
)
|
|
const totalRows = grid?.zones?.reduce((sum, z) => sum + z.rows.length, 0) ?? 0
|
|
|
|
if (!sessionId) {
|
|
return (
|
|
<div className="text-center py-12 text-gray-400">
|
|
Keine Session ausgewaehlt.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
|
<svg className="w-5 h-5 animate-spin" 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>
|
|
Grid wird geladen...
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<p className="text-sm text-red-700 dark:text-red-300">
|
|
Fehler: {error}
|
|
</p>
|
|
<button
|
|
onClick={buildGrid}
|
|
className="mt-2 text-xs px-3 py-1.5 bg-red-600 text-white rounded hover:bg-red-700"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!grid || !grid.zones.length) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-400 mb-4">Kein Grid vorhanden.</p>
|
|
<button
|
|
onClick={buildGrid}
|
|
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm"
|
|
>
|
|
Grid aus OCR-Ergebnissen erstellen
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const imageUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Review Stats Bar */}
|
|
<div className="flex items-center gap-4 text-xs flex-wrap">
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
{grid.summary.total_zones} Zone(n), {grid.summary.total_columns} Spalten,{' '}
|
|
{grid.summary.total_rows} Zeilen, {grid.summary.total_cells} Zellen
|
|
</span>
|
|
{grid.dictionary_detection?.is_dictionary && (
|
|
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
|
|
Woerterbuch ({Math.round(grid.dictionary_detection.confidence * 100)}%)
|
|
</span>
|
|
)}
|
|
{lowConfCells.length > 0 && (
|
|
<span className="px-2 py-0.5 rounded-full bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
|
|
{lowConfCells.length} niedrige Konfidenz
|
|
</span>
|
|
)}
|
|
<span className="text-gray-400 dark:text-gray-500">
|
|
{acceptedRows.size}/{totalRows} Zeilen akzeptiert
|
|
</span>
|
|
{acceptedRows.size < totalRows && (
|
|
<button
|
|
onClick={acceptAllRows}
|
|
className="text-teal-600 dark:text-teal-400 hover:text-teal-700 dark:hover:text-teal-300"
|
|
>
|
|
Alle akzeptieren
|
|
</button>
|
|
)}
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={() => {
|
|
const n = autoCorrectColumnPatterns()
|
|
if (n === 0) alert('Keine Muster-Korrekturen gefunden.')
|
|
else alert(`${n} Zelle(n) korrigiert (Muster-Vervollstaendigung).`)
|
|
}}
|
|
className="px-2.5 py-1 rounded text-xs border border-purple-200 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors"
|
|
title="Erkennt Muster wie p.70, p.71 und vervollstaendigt partielle Eintraege wie .65 zu p.65"
|
|
>
|
|
Auto-Korrektur
|
|
</button>
|
|
<button
|
|
onClick={() => setShowImage(!showImage)}
|
|
className={`px-2.5 py-1 rounded text-xs border transition-colors ${
|
|
showImage
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
|
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{showImage ? 'Bild ausblenden' : 'Bild einblenden'}
|
|
</button>
|
|
<span className="text-gray-400 dark:text-gray-500">
|
|
{grid.duration_seconds.toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
|
|
<GridToolbar
|
|
dirty={dirty}
|
|
saving={saving}
|
|
canUndo={canUndo}
|
|
canRedo={canRedo}
|
|
showOverlay={false}
|
|
onSave={saveGrid}
|
|
onUndo={undo}
|
|
onRedo={redo}
|
|
onRebuild={buildGrid}
|
|
onToggleOverlay={() => setShowImage(!showImage)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Split View: Image left + Grid right */}
|
|
<div
|
|
className={showImage ? 'grid grid-cols-2 gap-3' : ''}
|
|
style={{ minHeight: '55vh' }}
|
|
>
|
|
{/* Left: Original Image with Layout Editor */}
|
|
{showImage && (
|
|
<ImageLayoutEditor
|
|
imageUrl={imageUrl}
|
|
zones={grid.zones}
|
|
imageWidth={grid.image_width}
|
|
layoutDividers={grid.layout_dividers}
|
|
zoom={zoom}
|
|
onZoomChange={setZoom}
|
|
onColumnDividerMove={updateColumnDivider}
|
|
onHorizontalsChange={updateLayoutHorizontals}
|
|
onCommitUndo={commitUndoPoint}
|
|
onSplitColumnAt={splitColumnAt}
|
|
onDeleteColumn={deleteColumn}
|
|
/>
|
|
)}
|
|
|
|
{/* Right: Grid with row-accept buttons */}
|
|
<div className="space-y-3">
|
|
|
|
{/* Zone tables with row-accept buttons */}
|
|
{(() => {
|
|
// Group consecutive zones with same vsplit_group
|
|
const groups: GridZone[][] = []
|
|
for (const zone of grid.zones) {
|
|
const prev = groups[groups.length - 1]
|
|
if (
|
|
prev &&
|
|
zone.vsplit_group != null &&
|
|
prev[0].vsplit_group === zone.vsplit_group
|
|
) {
|
|
prev.push(zone)
|
|
} else {
|
|
groups.push([zone])
|
|
}
|
|
}
|
|
return groups.map((group) => (
|
|
<div key={group[0].vsplit_group ?? group[0].zone_index}>
|
|
{/* Row-accept sidebar wraps each zone group */}
|
|
<div className="flex gap-1">
|
|
{/* Accept buttons column */}
|
|
<div className="flex-shrink-0 pt-[52px]">
|
|
{group[0].rows.map((row) => {
|
|
const key = `${group[0].zone_index}-${row.index}`
|
|
const isAccepted = acceptedRows.has(key)
|
|
return (
|
|
<button
|
|
key={row.index}
|
|
onClick={() =>
|
|
acceptRow(group[0].zone_index, row.index)
|
|
}
|
|
className={`w-6 h-6 mb-px rounded flex items-center justify-center transition-colors ${
|
|
isAccepted
|
|
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-300 dark:text-gray-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/30 hover:text-emerald-500'
|
|
}`}
|
|
title={
|
|
isAccepted
|
|
? 'Klick zum Entfernen'
|
|
: 'Zeile als korrekt markieren'
|
|
}
|
|
>
|
|
<svg
|
|
className="w-3.5 h-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2.5}
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Grid table(s) */}
|
|
<div
|
|
className={`flex-1 min-w-0 ${group.length > 1 ? 'flex gap-2' : ''}`}
|
|
>
|
|
{group.map((zone) => (
|
|
<div
|
|
key={zone.zone_index}
|
|
className={`${group.length > 1 ? 'flex-1 min-w-0' : ''} bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden`}
|
|
>
|
|
<GridTable
|
|
zone={zone}
|
|
layoutMetrics={grid.layout_metrics}
|
|
selectedCell={selectedCell}
|
|
selectedCells={selectedCells}
|
|
onSelectCell={setSelectedCell}
|
|
onToggleCellSelection={toggleCellSelection}
|
|
onCellTextChange={updateCellText}
|
|
onToggleColumnBold={toggleColumnBold}
|
|
onToggleRowHeader={toggleRowHeader}
|
|
onNavigate={handleNavigate}
|
|
onDeleteColumn={deleteColumn}
|
|
onAddColumn={addColumn}
|
|
onDeleteRow={deleteRow}
|
|
onAddRow={addRow}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Multi-select toolbar */}
|
|
{selectedCells.size > 0 && (
|
|
<div className="flex items-center gap-3 px-3 py-2 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800 rounded-lg text-xs">
|
|
<span className="text-teal-700 dark:text-teal-300 font-medium">
|
|
{selectedCells.size} Zellen markiert
|
|
</span>
|
|
<button
|
|
onClick={toggleSelectedBold}
|
|
className="px-2.5 py-1 bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors font-medium"
|
|
>
|
|
B Fett umschalten
|
|
</button>
|
|
<button
|
|
onClick={clearCellSelection}
|
|
className="px-2 py-1 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-200 transition-colors"
|
|
>
|
|
Auswahl aufheben (Esc)
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tips + Next */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-[11px] text-gray-400 dark:text-gray-500 flex items-center gap-4">
|
|
<span>Tab: naechste Zelle</span>
|
|
<span>Pfeiltasten: Navigation</span>
|
|
<span>Ctrl+Klick: Mehrfachauswahl</span>
|
|
<span>Ctrl+B: Fett</span>
|
|
<span>Ctrl+Z/Y: Undo/Redo</span>
|
|
<span>Ctrl+S: Speichern</span>
|
|
</div>
|
|
{onNext && (
|
|
<button
|
|
onClick={async () => {
|
|
if (dirty) await saveGrid()
|
|
onNext()
|
|
}}
|
|
className="px-4 py-2 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700 transition-colors"
|
|
>
|
|
Fertig
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|