From 4e668660a7d3ad9f458bce902b313faa156ca355 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 23 Mar 2026 16:27:12 +0100 Subject: [PATCH] feat: add Woerterbuch category + column add/delete in grid editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New document category "Woerterbuch" (frontend type + backend validation) - Column delete: hover column header → red "x" button (with confirmation) - Column add: hover column header → "+" button inserts after that column - Both operations support undo/redo, update cell IDs and summary - Available in both GridEditor and StepGridReview (Kombi last step) Co-Authored-By: Claude Opus 4.6 --- .../app/(admin)/ai/ocr-pipeline/types.ts | 3 +- .../components/grid-editor/GridEditor.tsx | 4 + .../components/grid-editor/GridTable.tsx | 38 +++++- .../components/grid-editor/useGridEditor.ts | 127 ++++++++++++++++++ .../ocr-pipeline/StepGridReview.tsx | 4 + .../backend/ocr_pipeline_common.py | 2 +- 6 files changed, 174 insertions(+), 4 deletions(-) diff --git a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts index d8c046f..c68f3bd 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts +++ b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts @@ -8,11 +8,12 @@ export interface PipelineStep { } export type DocumentCategory = - | 'vokabelseite' | 'buchseite' | 'arbeitsblatt' | 'klausurseite' + | 'vokabelseite' | 'woerterbuch' | 'buchseite' | 'arbeitsblatt' | 'klausurseite' | 'mathearbeit' | 'statistik' | 'zeitung' | 'formular' | 'handschrift' | 'sonstiges' export const DOCUMENT_CATEGORIES: { value: DocumentCategory; label: string; icon: string }[] = [ { value: 'vokabelseite', label: 'Vokabelseite', icon: '📖' }, + { value: 'woerterbuch', label: 'Woerterbuch', icon: '📕' }, { value: 'buchseite', label: 'Buchseite', icon: '📚' }, { value: 'arbeitsblatt', label: 'Arbeitsblatt', icon: '📝' }, { value: 'klausurseite', label: 'Klausurseite', icon: '📄' }, diff --git a/admin-lehrer/components/grid-editor/GridEditor.tsx b/admin-lehrer/components/grid-editor/GridEditor.tsx index 9b56f53..da09249 100644 --- a/admin-lehrer/components/grid-editor/GridEditor.tsx +++ b/admin-lehrer/components/grid-editor/GridEditor.tsx @@ -32,6 +32,8 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) { canUndo, canRedo, getAdjacentCell, + deleteColumn, + addColumn, } = useGridEditor(sessionId) const [showOverlay, setShowOverlay] = useState(false) @@ -219,6 +221,8 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) { onToggleColumnBold={toggleColumnBold} onToggleRowHeader={toggleRowHeader} onNavigate={handleNavigate} + onDeleteColumn={deleteColumn} + onAddColumn={addColumn} /> ) : ( diff --git a/admin-lehrer/components/grid-editor/GridTable.tsx b/admin-lehrer/components/grid-editor/GridTable.tsx index 15f42cb..82d7e2b 100644 --- a/admin-lehrer/components/grid-editor/GridTable.tsx +++ b/admin-lehrer/components/grid-editor/GridTable.tsx @@ -12,6 +12,8 @@ interface GridTableProps { onToggleColumnBold: (zoneIndex: number, colIndex: number) => void onToggleRowHeader: (zoneIndex: number, rowIndex: number) => void onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void + onDeleteColumn?: (zoneIndex: number, colIndex: number) => void + onAddColumn?: (zoneIndex: number, afterColIndex: number) => void } /** Gutter width for row numbers (px). */ @@ -32,6 +34,8 @@ export function GridTable({ onToggleColumnBold, onToggleRowHeader, onNavigate, + onDeleteColumn, + onAddColumn, }: GridTableProps) { const containerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(0) @@ -245,11 +249,11 @@ export function GridTable({ {/* Header: row-number corner */}
- {/* Header: column labels with resize handles */} + {/* Header: column labels with resize handles + delete/add */} {zone.columns.map((col, ci) => (
onToggleColumnBold(zone.zone_index, col.index)} @@ -263,6 +267,36 @@ export function GridTable({ )}
+ {/* Delete column button (visible on hover) */} + {onDeleteColumn && numCols > 1 && ( + + )} + {/* Add column button (visible on hover, after this column) */} + {onAddColumn && ( + + )} {/* Right-edge resize handle */} {ci < numCols - 1 && (
{ + if (!grid) return + const zone = grid.zones.find((z) => z.zone_index === zoneIndex) + if (!zone || zone.columns.length <= 1) return // keep at least 1 column + pushUndo(grid.zones) + + setGrid((prev) => { + if (!prev) return prev + return { + ...prev, + zones: prev.zones.map((z) => { + if (z.zone_index !== zoneIndex) return z + const newColumns = z.columns + .filter((c) => c.index !== colIndex) + .map((c, i) => ({ ...c, index: i, label: `column_${i + 1}` })) + const newCells = z.cells + .filter((c) => c.col_index !== colIndex) + .map((c) => { + const newCI = c.col_index > colIndex ? c.col_index - 1 : c.col_index + return { + ...c, + col_index: newCI, + cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`, + } + }) + return { ...z, columns: newColumns, cells: newCells } + }), + summary: { + ...prev.summary, + total_columns: prev.summary.total_columns - 1, + total_cells: prev.zones.reduce( + (sum, z) => + sum + + (z.zone_index === zoneIndex + ? z.cells.filter((c) => c.col_index !== colIndex).length + : z.cells.length), + 0, + ), + }, + } + }) + setDirty(true) + }, + [grid, pushUndo], + ) + + const addColumn = useCallback( + (zoneIndex: number, afterColIndex: number) => { + if (!grid) return + const zone = grid.zones.find((z) => z.zone_index === zoneIndex) + if (!zone) return + pushUndo(grid.zones) + + const newColIndex = afterColIndex + 1 + + setGrid((prev) => { + if (!prev) return prev + return { + ...prev, + zones: prev.zones.map((z) => { + if (z.zone_index !== zoneIndex) return z + // Shift existing columns + const shiftedCols = z.columns.map((c) => + c.index > afterColIndex ? { ...c, index: c.index + 1, label: `column_${c.index + 2}` } : c, + ) + // Insert new column + const refCol = z.columns.find((c) => c.index === afterColIndex) || z.columns[z.columns.length - 1] + const newCol = { + index: newColIndex, + label: `column_${newColIndex + 1}`, + x_min_px: refCol.x_max_px, + x_max_px: refCol.x_max_px + (refCol.x_max_px - refCol.x_min_px), + x_min_pct: refCol.x_max_pct, + x_max_pct: Math.min(100, refCol.x_max_pct + (refCol.x_max_pct - refCol.x_min_pct)), + bold: false, + } + const allCols = [...shiftedCols, newCol].sort((a, b) => a.index - b.index) + + // Shift existing cells and create empty cells for new column + const shiftedCells = z.cells.map((c) => { + if (c.col_index > afterColIndex) { + const newCI = c.col_index + 1 + return { + ...c, + col_index: newCI, + cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`, + } + } + return c + }) + // Create empty cells for each row + const newCells = z.rows.map((row) => ({ + cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`, + zone_index: zoneIndex, + row_index: row.index, + col_index: newColIndex, + col_type: `column_${newColIndex + 1}`, + text: '', + confidence: 0, + bbox_px: { x: 0, y: 0, w: 0, h: 0 }, + bbox_pct: { x: 0, y: 0, w: 0, h: 0 }, + word_boxes: [], + ocr_engine: 'manual', + is_bold: false, + })) + + return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] } + }), + summary: { + ...prev.summary, + total_columns: prev.summary.total_columns + 1, + total_cells: prev.summary.total_cells + (zone?.rows.length ?? 0), + }, + } + }) + setDirty(true) + }, + [grid, pushUndo], + ) + // ------------------------------------------------------------------ // Undo / Redo // ------------------------------------------------------------------ @@ -284,5 +409,7 @@ export function useGridEditor(sessionId: string | null) { canUndo, canRedo, getAdjacentCell, + deleteColumn, + addColumn, } } diff --git a/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx b/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx index ad1a488..0339d6c 100644 --- a/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx +++ b/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx @@ -41,6 +41,8 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { canUndo, canRedo, getAdjacentCell, + deleteColumn, + addColumn, } = useGridEditor(sessionId) const [showImage, setShowImage] = useState(true) @@ -373,6 +375,8 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { onToggleColumnBold={toggleColumnBold} onToggleRowHeader={toggleRowHeader} onNavigate={handleNavigate} + onDeleteColumn={deleteColumn} + onAddColumn={addColumn} />
))} diff --git a/klausur-service/backend/ocr_pipeline_common.py b/klausur-service/backend/ocr_pipeline_common.py index 3a13cff..789df26 100644 --- a/klausur-service/backend/ocr_pipeline_common.py +++ b/klausur-service/backend/ocr_pipeline_common.py @@ -146,7 +146,7 @@ class DewarpGroundTruthRequest(BaseModel): VALID_DOCUMENT_CATEGORIES = { - 'vokabelseite', 'buchseite', 'arbeitsblatt', 'klausurseite', + 'vokabelseite', 'woerterbuch', 'buchseite', 'arbeitsblatt', 'klausurseite', 'mathearbeit', 'statistik', 'zeitung', 'formular', 'handschrift', 'sonstiges', }