'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) | 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, setCellColor, ipaMode, setIpaMode, syllableMode, setSyllableMode, } = useGridEditor(sessionId) const [showImage, setShowImage] = useState(true) const [zoom, setZoom] = useState(100) const [acceptedRows, setAcceptedRows] = useState>(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() 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 (
Keine Session ausgewaehlt.
) } if (loading) { return (
Grid wird geladen...
) } if (error) { return (

Fehler: {error}

) } if (!grid || !grid.zones.length) { return (

Kein Grid vorhanden.

) } const imageUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` return (
{/* Review Stats Bar */}
{grid.summary.total_zones} Zone(n), {grid.summary.total_columns} Spalten,{' '} {grid.summary.total_rows} Zeilen, {grid.summary.total_cells} Zellen {grid.dictionary_detection?.is_dictionary && ( Woerterbuch ({Math.round(grid.dictionary_detection.confidence * 100)}%) )} {grid.page_number?.text && ( S. {grid.page_number.number ?? grid.page_number.text} )} {lowConfCells.length > 0 && ( {lowConfCells.length} niedrige Konfidenz )} {acceptedRows.size}/{totalRows} Zeilen akzeptiert {acceptedRows.size < totalRows && ( )}
{grid.duration_seconds.toFixed(1)}s
{/* Toolbar */}
setShowImage(!showImage)} onIpaModeChange={setIpaMode} onSyllableModeChange={setSyllableMode} />
{/* Split View: Image left + Grid right */}
{/* Left: Original Image with Layout Editor */} {showImage && ( )} {/* Right: Grid with row-accept buttons */}
{/* 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) => (
{/* Row-accept sidebar wraps each zone group */}
{/* Accept buttons column */}
{group[0].rows.map((row) => { const key = `${group[0].zone_index}-${row.index}` const isAccepted = acceptedRows.has(key) return ( ) })}
{/* Grid table(s) */}
1 ? 'flex gap-2' : ''}`} > {group.map((zone) => (
1 ? 'flex-1 min-w-0' : ''} bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700`} >
))}
)) })()}
{/* Multi-select toolbar */} {selectedCells.size > 0 && (
{selectedCells.size} Zellen markiert
)} {/* Tips + Next */}
Tab: naechste Zelle Pfeiltasten: Navigation Ctrl+Klick: Mehrfachauswahl Ctrl+B: Fett Rechtsklick: Farbe Ctrl+Z/Y: Undo/Redo Ctrl+S: Speichern
{onNext && ( )}
) }