Files
breakpilot-lehrer/admin-lehrer/components/ocr-kombi/SpreadsheetView.tsx
Benjamin Admin d4353d76fb
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 36s
CI / test-go-edu-search (push) Successful in 36s
CI / test-python-klausur (push) Failing after 2m21s
CI / test-python-agent-core (push) Successful in 31s
CI / test-nodejs-website (push) Successful in 31s
SpreadsheetView: multi-sheet tabs instead of unified single sheet
Each zone becomes its own Excel sheet tab with independent column widths:
- Sheet "Vokabeln": main content zone with EN/DE/example columns
- Sheet "Pounds and euros": Box 1 with its own 4-column layout
- Sheet "German leihen": Box 2 with single column for flowing text

This solves the column-width conflict: boxes have different column
widths optimized for their content, which is impossible in a single
unified sheet (Excel limitation: column width is per-column, not per-cell).

Sheet tabs visible at bottom (showSheetTabs: true).
Box sheets get colored tab (from box_bg_hex).
First sheet active by default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:51:21 +02:00

180 lines
5.3 KiB
TypeScript

'use client'
/**
* SpreadsheetView — Fortune Sheet with multi-sheet support.
*
* 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'
const Workbook = dynamic(
() => import('@fortune-sheet/react').then((m) => m.Workbook),
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Spreadsheet wird geladen...</div> },
)
import '@fortune-sheet/react/dist/index.css'
import type { GridZone, GridEditorCell } from '@/components/grid-editor/types'
interface SpreadsheetViewProps {
/** Multi-zone grid data (grid_editor_result) */
gridData: any
height?: number
}
/** 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 || ''
// 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}`
}
const numRows = zone.rows?.length || 0
const numCols = zone.columns?.length || 1
// Build celldata
const celldata: any[] = []
const merges: Record<string, any> = {}
for (const cell of (zone.cells || [])) {
const r = cell.row_index
const c = cell.col_index
const text = cell.text ?? ''
const v: any = {
v: text,
m: text,
}
// 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
const color = cell.color_override
?? cell.word_boxes?.find((wb: any) => wb.color_name && wb.color_name !== 'black')?.color
if (color) v.fc = color
// Multi-line: text wrap
if (text.includes('\n')) v.tb = '2'
celldata.push({ r, c, v })
// Colspan → merge
const colspan = cell.colspan || 0
if (colspan > 1 || cell.col_type === 'spanning_header') {
const cs = colspan || numCols
merges[`${r}_${c}`] = { r, c, rs: 1, cs }
}
}
// Column widths — optimized per zone
const columnlen: Record<string, number> = {}
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 rowlen: Record<string, number> = {}
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(24, 24 * maxLines)
}
// Box border around entire content
const borderInfo: any[] = []
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,
id: `zone_${zone.zone_index}`,
celldata,
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,
rowlen,
borderInfo: borderInfo.length > 0 ? borderInfo : undefined,
},
}
}
export function SpreadsheetView({ gridData, height = 600 }: SpreadsheetViewProps) {
const sheets = useMemo(() => {
if (!gridData?.zones) return []
// 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 <div className="p-4 text-center text-gray-400">Keine Daten für Spreadsheet.</div>
}
return (
<div style={{ width: '100%', height: `${height}px` }}>
<Workbook
data={sheets}
lang="en"
showToolbar
showFormulaBar={false}
showSheetTabs
toolbarItems={[
'undo', 'redo', '|',
'font-bold', 'font-italic', 'font-strikethrough', '|',
'font-color', 'background', '|',
'font-size', '|',
'horizontal-align', 'vertical-align', '|',
'text-wrap', 'merge-cell', '|',
'border',
]}
/>
</div>
)
}