diff --git a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx
index ed690d6..b38db0f 100644
--- a/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx
+++ b/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx
@@ -1,85 +1,81 @@
'use client'
/**
- * SpreadsheetView — Fortune Sheet integration for unified grid display.
+ * SpreadsheetView — Fortune Sheet with multi-sheet support.
*
- * Converts unified grid data into Fortune Sheet format and renders
- * a full-featured Excel-like spreadsheet editor.
+ * Each zone (content + boxes) becomes its own Excel sheet tab,
+ * so each can have independent column widths optimized for its content.
*/
import { useMemo } from 'react'
import dynamic from 'next/dynamic'
-// Lazy-load Fortune Sheet (uses canvas, SSR-incompatible)
const Workbook = dynamic(
() => import('@fortune-sheet/react').then((m) => m.Workbook),
{ ssr: false, loading: () =>
Spreadsheet wird geladen...
},
)
-// Import Fortune Sheet CSS
import '@fortune-sheet/react/dist/index.css'
import type { GridZone, GridEditorCell } from '@/components/grid-editor/types'
interface SpreadsheetViewProps {
- unifiedGrid: any // unified_grid_result from backend
+ /** Multi-zone grid data (grid_editor_result) */
+ gridData: any
height?: number
}
-/**
- * Convert unified grid data to Fortune Sheet format.
- */
-function unifiedGridToSheet(grid: any) {
- const zone: GridZone | undefined = grid?.zones?.[0]
- if (!zone) return null
+/** Convert a single zone to a Fortune Sheet sheet object. */
+function zoneToSheet(zone: GridZone, sheetIndex: number, isFirst: boolean): any {
+ const isBox = zone.zone_type === 'box'
+ const boxColor = (zone as any).box_bg_hex || ''
+ const layoutType = (zone as any).box_layout_type || ''
- const numRows = zone.rows.length
- const numCols = zone.columns.length
+ // Sheet name
+ let name: string
+ if (!isBox) {
+ name = 'Vokabeln'
+ } else {
+ // Use first cell text as name (truncated)
+ const firstText = zone.cells?.[0]?.text ?? `Box ${sheetIndex}`
+ const cleaned = firstText.replace(/[^\w\s\u00C0-\u024F]/g, '').trim()
+ name = cleaned.length > 20 ? cleaned.slice(0, 20) + '...' : cleaned || `Box ${sheetIndex}`
+ }
- // Build celldata array
+ const numRows = zone.rows?.length || 0
+ const numCols = zone.columns?.length || 1
+
+ // Build celldata
const celldata: any[] = []
const merges: Record = {}
- // Build cell lookup
- const cellMap = new Map()
- for (const cell of zone.cells) {
- cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
- }
-
- for (const cell of zone.cells) {
+ for (const cell of (zone.cells || [])) {
const r = cell.row_index
const c = cell.col_index
const text = cell.text ?? ''
- const isBox = cell.source_zone_type === 'box'
- const boxHex = cell.box_region?.bg_hex
- // Cell value
const v: any = {
v: text,
m: text,
}
// Bold
- if (cell.is_bold) {
+ if (cell.is_bold) v.bl = 1
+
+ // Header row styling
+ const row = zone.rows?.find((rr) => rr.index === r)
+ if (row?.is_header) {
v.bl = 1
+ v.bg = '#f0f4ff'
}
- // Text color from word_boxes or color_override
+ // Text color
const color = cell.color_override
?? cell.word_boxes?.find((wb: any) => wb.color_name && wb.color_name !== 'black')?.color
- if (color) {
- v.fc = color
- }
+ if (color) v.fc = color
- // Box background tint
- if (isBox && boxHex) {
- v.bg = `${boxHex}15` // very light tint
- }
-
- // Multi-line: enable text wrap
- if (text.includes('\n')) {
- v.tb = '2' // text wrap
- }
+ // Multi-line: text wrap
+ if (text.includes('\n')) v.tb = '2'
celldata.push({ r, c, v })
@@ -91,51 +87,46 @@ function unifiedGridToSheet(grid: any) {
}
}
- // Column widths from zone columns
+ // Column widths — optimized per zone
const columnlen: Record = {}
- for (const col of zone.columns) {
- const w = (col.x_max_px ?? 0) - (col.x_min_px ?? 0)
- columnlen[String(col.index)] = Math.max(60, Math.round(w * 0.4)) // scale down for screen
+ for (const col of (zone.columns || [])) {
+ const pxW = (col.x_max_px ?? 0) - (col.x_min_px ?? 0)
+ // Scale: wider for small column counts (boxes), narrower for many columns
+ const scaleFactor = numCols <= 2 ? 0.6 : 0.4
+ columnlen[String(col.index)] = Math.max(80, Math.round(pxW * scaleFactor))
}
// Row heights
- const dominantH = grid.dominant_row_h || 30
const rowlen: Record = {}
- for (const row of zone.rows) {
- // Count max lines in this row's cells
- const rowCells = zone.cells.filter((c: any) => c.row_index === row.index)
+ for (const row of (zone.rows || [])) {
+ const rowCells = (zone.cells || []).filter((c: any) => c.row_index === row.index)
const maxLines = Math.max(1, ...rowCells.map((c: any) => (c.text ?? '').split('\n').length))
- rowlen[String(row.index)] = Math.max(22, Math.round(dominantH * 0.6 * maxLines))
+ rowlen[String(row.index)] = Math.max(24, 24 * maxLines)
}
- // Box region borders
+ // Box border around entire content
const borderInfo: any[] = []
- // Collect box cells and draw borders around box regions
- const boxCells = zone.cells.filter((c: any) => c.source_zone_type === 'box' && c.box_region?.border)
- if (boxCells.length > 0) {
- const boxHex = boxCells[0].box_region?.bg_hex || '#2563eb'
- const boxRows = [...new Set(boxCells.map((c: any) => c.row_index))].sort((a: number, b: number) => a - b)
- const boxCols = [...new Set(boxCells.map((c: any) => c.col_index))].sort((a: number, b: number) => a - b)
- if (boxRows.length > 0 && boxCols.length > 0) {
- borderInfo.push({
- rangeType: 'range',
- borderType: 'border-all',
- color: boxHex,
- style: 1,
- range: [{
- row: [boxRows[0], boxRows[boxRows.length - 1]],
- column: [boxCols[0], boxCols[boxCols.length - 1]],
- }],
- })
- }
+ if (isBox && boxColor && numRows > 0 && numCols > 0) {
+ borderInfo.push({
+ rangeType: 'range',
+ borderType: 'border-outside',
+ color: boxColor,
+ style: 2, // thick
+ range: [{
+ row: [0, numRows - 1],
+ column: [0, numCols - 1],
+ }],
+ })
}
return {
- name: 'Seite',
- id: 'unified',
+ name,
+ id: `zone_${zone.zone_index}`,
celldata,
- row: numRows,
- column: numCols,
+ row: Math.max(numRows, 3), // minimum 3 rows
+ column: Math.max(numCols, 1),
+ status: isFirst ? 1 : 0, // first sheet is active
+ color: isBox ? boxColor : undefined,
config: {
merge: Object.keys(merges).length > 0 ? merges : undefined,
columnlen,
@@ -145,21 +136,34 @@ function unifiedGridToSheet(grid: any) {
}
}
-export function SpreadsheetView({ unifiedGrid, height = 600 }: SpreadsheetViewProps) {
- const sheet = useMemo(() => unifiedGridToSheet(unifiedGrid), [unifiedGrid])
+export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps) {
+ const sheets = useMemo(() => {
+ if (!gridData?.zones) return []
- if (!sheet) {
+ // Sort: content zones first, then boxes by y-position
+ const sorted = [...gridData.zones].sort((a: GridZone, b: GridZone) => {
+ if (a.zone_type === 'content' && b.zone_type !== 'content') return -1
+ if (a.zone_type !== 'content' && b.zone_type === 'content') return 1
+ return (a.bbox_px?.y ?? 0) - (b.bbox_px?.y ?? 0)
+ })
+
+ return sorted
+ .filter((z: GridZone) => z.cells && z.cells.length > 0)
+ .map((z: GridZone, i: number) => zoneToSheet(z, i, i === 0))
+ }, [gridData])
+
+ if (sheets.length === 0) {
return Keine Daten für Spreadsheet.
}
return (
{
if (!sessionId) return
+ // Load multi-zone grid (for spreadsheet mode)
+ gridEditor.loadGrid()
+ // Load unified grid (for grid mode)
;(async () => {
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/unified-grid`)
if (res.ok) {
setUnifiedGrid(await res.json())
} else {
- // Not built yet — build it
buildUnified()
}
} catch {
@@ -170,8 +172,8 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
{/* RIGHT: Spreadsheet or Grid view */}
- {viewMode === 'spreadsheet' && unifiedGrid ? (
-
+ {viewMode === 'spreadsheet' && (unifiedGrid || gridEditor.grid) ? (
+
) : viewMode === 'grid' && unifiedZone ? (