feat(admin-v2): Major SDK/Compliance overhaul and new modules
SDK modules added/enhanced: - compliance-hub, compliance-scope, consent-management, notfallplan - audit-report, workflow, source-policy, dsms - advisory-board documentation section - TOM dashboard components, TOM generator SDM mapping - DSFA: mitigation library, risk catalog, threshold analysis, source attribution - VVT: baseline catalog, profiling engine, types - Loeschfristen: baseline catalog, compliance engine, export, profiling, types - Compliance scope: engine, profiling, golden tests, types Existing SDK pages updated: - dsfa/[id], tom, vvt, loeschfristen, advisory-board — expanded functionality - SDKSidebar, StepHeader — new navigation items and layout - SDK layout, context, types — expanded type system Other admin-v2 changes: - AI agents page, RAG pipeline DSFA integration - GridOverlay component updates - Companion feature (development + education) - Compliance advisor SOUL definition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,15 @@
|
||||
* GridOverlay Component
|
||||
*
|
||||
* SVG overlay for displaying detected OCR grid structure on document images.
|
||||
* Shows recognized (green), problematic (orange), manual (blue), and empty (transparent) cells.
|
||||
* Supports click-to-edit for problematic cells.
|
||||
* Features:
|
||||
* - Cell status visualization (recognized/problematic/manual/empty)
|
||||
* - 1mm grid overlay for A4 pages (210x297mm)
|
||||
* - Text at original bounding-box positions
|
||||
* - Editable text (contentEditable) at original positions
|
||||
* - Click-to-edit for cells
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CellStatus = 'empty' | 'recognized' | 'problematic' | 'manual'
|
||||
@@ -24,6 +28,10 @@ export interface GridCell {
|
||||
confidence: number
|
||||
status: CellStatus
|
||||
column_type?: 'english' | 'german' | 'example' | 'unknown'
|
||||
x_mm?: number
|
||||
y_mm?: number
|
||||
width_mm?: number
|
||||
height_mm?: number
|
||||
}
|
||||
|
||||
export interface GridData {
|
||||
@@ -42,57 +50,198 @@ export interface GridData {
|
||||
total: number
|
||||
coverage: number
|
||||
}
|
||||
page_dimensions?: {
|
||||
width_mm: number
|
||||
height_mm: number
|
||||
format: string
|
||||
}
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface GridOverlayProps {
|
||||
grid: GridData
|
||||
imageUrl?: string
|
||||
onCellClick?: (cell: GridCell) => void
|
||||
onCellTextChange?: (cell: GridCell, newText: string) => void
|
||||
selectedCell?: GridCell | null
|
||||
showEmpty?: boolean
|
||||
showLabels?: boolean
|
||||
showNumbers?: boolean // Show block numbers in cells
|
||||
highlightedBlockNumber?: number | null // Highlight specific block
|
||||
showNumbers?: boolean
|
||||
showTextLabels?: boolean
|
||||
showMmGrid?: boolean
|
||||
showTextAtPosition?: boolean
|
||||
editableText?: boolean
|
||||
highlightedBlockNumber?: number | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Status colors
|
||||
const STATUS_COLORS = {
|
||||
recognized: {
|
||||
fill: 'rgba(34, 197, 94, 0.2)', // green-500 with opacity
|
||||
stroke: '#22c55e', // green-500
|
||||
fill: 'rgba(34, 197, 94, 0.2)',
|
||||
stroke: '#22c55e',
|
||||
hoverFill: 'rgba(34, 197, 94, 0.3)',
|
||||
},
|
||||
problematic: {
|
||||
fill: 'rgba(249, 115, 22, 0.3)', // orange-500 with opacity
|
||||
stroke: '#f97316', // orange-500
|
||||
fill: 'rgba(249, 115, 22, 0.3)',
|
||||
stroke: '#f97316',
|
||||
hoverFill: 'rgba(249, 115, 22, 0.4)',
|
||||
},
|
||||
manual: {
|
||||
fill: 'rgba(59, 130, 246, 0.2)', // blue-500 with opacity
|
||||
stroke: '#3b82f6', // blue-500
|
||||
fill: 'rgba(59, 130, 246, 0.2)',
|
||||
stroke: '#3b82f6',
|
||||
hoverFill: 'rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
empty: {
|
||||
fill: 'transparent',
|
||||
stroke: 'rgba(148, 163, 184, 0.3)', // slate-400 with opacity
|
||||
stroke: 'rgba(148, 163, 184, 0.3)',
|
||||
hoverFill: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
}
|
||||
|
||||
// A4 dimensions for mm grid
|
||||
const A4_WIDTH_MM = 210
|
||||
const A4_HEIGHT_MM = 297
|
||||
|
||||
// Helper to calculate block number (1-indexed, row-by-row)
|
||||
export function getCellBlockNumber(cell: GridCell, grid: GridData): number {
|
||||
return cell.row * grid.columns + cell.col + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 1mm Grid SVG Lines for A4 format.
|
||||
* Renders inside a viewBox="0 0 100 100" (percentage-based).
|
||||
*/
|
||||
function MmGridLines() {
|
||||
const lines: React.ReactNode[] = []
|
||||
|
||||
// Vertical lines: 210 lines for 210mm
|
||||
for (let mm = 0; mm <= A4_WIDTH_MM; mm++) {
|
||||
const x = (mm / A4_WIDTH_MM) * 100
|
||||
const isCm = mm % 10 === 0
|
||||
lines.push(
|
||||
<line
|
||||
key={`v-${mm}`}
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={100}
|
||||
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
|
||||
strokeWidth={isCm ? 0.08 : 0.03}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Horizontal lines: 297 lines for 297mm
|
||||
for (let mm = 0; mm <= A4_HEIGHT_MM; mm++) {
|
||||
const y = (mm / A4_HEIGHT_MM) * 100
|
||||
const isCm = mm % 10 === 0
|
||||
lines.push(
|
||||
<line
|
||||
key={`h-${mm}`}
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={100}
|
||||
y2={y}
|
||||
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
|
||||
strokeWidth={isCm ? 0.08 : 0.03}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <g style={{ pointerEvents: 'none' }}>{lines}</g>
|
||||
}
|
||||
|
||||
/**
|
||||
* Positioned text overlay using absolute-positioned HTML divs.
|
||||
* Each cell's text appears at its bounding-box position with matching font size.
|
||||
*/
|
||||
function PositionedTextLayer({
|
||||
cells,
|
||||
editable,
|
||||
onTextChange,
|
||||
}: {
|
||||
cells: GridCell[]
|
||||
editable: boolean
|
||||
onTextChange?: (cell: GridCell, text: string) => void
|
||||
}) {
|
||||
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ pointerEvents: editable ? 'auto' : 'none' }}>
|
||||
{cells.map((cell) => {
|
||||
if (cell.status === 'empty' || !cell.text) return null
|
||||
|
||||
const cellKey = `pos-${cell.row}-${cell.col}`
|
||||
const isHovered = hoveredCell === cellKey
|
||||
// Estimate font size from cell height: height_pct maps to roughly pt size
|
||||
// A4 at 100% = 297mm height. Cell height in % * 297mm / 100 = height_mm
|
||||
// Font size ~= height_mm * 2.2 (roughly matching print)
|
||||
const heightMm = cell.height_mm ?? (cell.height / 100 * A4_HEIGHT_MM)
|
||||
const fontSizePt = Math.max(6, Math.min(18, heightMm * 2.2))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cellKey}
|
||||
className={cn(
|
||||
'absolute overflow-hidden transition-colors duration-100',
|
||||
editable && 'cursor-text hover:bg-yellow-100/40',
|
||||
isHovered && !editable && 'bg-blue-100/30',
|
||||
)}
|
||||
style={{
|
||||
left: `${cell.x}%`,
|
||||
top: `${cell.y}%`,
|
||||
width: `${cell.width}%`,
|
||||
height: `${cell.height}%`,
|
||||
fontSize: `${fontSizePt}pt`,
|
||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||
lineHeight: 1.1,
|
||||
color: cell.status === 'manual' ? '#1e40af' : '#1a1a1a',
|
||||
padding: '0 1px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCell(cellKey)}
|
||||
onMouseLeave={() => setHoveredCell(null)}
|
||||
>
|
||||
{editable ? (
|
||||
<span
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="outline-none w-full"
|
||||
style={{ minHeight: '1em' }}
|
||||
onBlur={(e) => {
|
||||
const newText = e.currentTarget.textContent ?? ''
|
||||
if (newText !== cell.text && onTextChange) {
|
||||
onTextChange(cell, newText)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{cell.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{cell.text}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GridOverlay({
|
||||
grid,
|
||||
imageUrl,
|
||||
onCellClick,
|
||||
onCellTextChange,
|
||||
selectedCell,
|
||||
showEmpty = false,
|
||||
showLabels = true,
|
||||
showNumbers = false,
|
||||
showTextLabels = false,
|
||||
showMmGrid = false,
|
||||
showTextAtPosition = false,
|
||||
editableText = false,
|
||||
highlightedBlockNumber,
|
||||
className,
|
||||
}: GridOverlayProps) {
|
||||
@@ -125,6 +274,9 @@ export function GridOverlay({
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* 1mm Grid */}
|
||||
{showMmGrid && <MmGridLines />}
|
||||
|
||||
{/* Column type labels */}
|
||||
{showLabels && grid.column_types.length > 0 && (
|
||||
<g>
|
||||
@@ -150,15 +302,14 @@ export function GridOverlay({
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Grid cells */}
|
||||
{flatCells.map((cell) => {
|
||||
{/* Grid cells (skip if showing text at position to avoid double rendering) */}
|
||||
{!showTextAtPosition && flatCells.map((cell) => {
|
||||
const colors = STATUS_COLORS[cell.status]
|
||||
const isSelected = selectedCell?.row === cell.row && selectedCell?.col === cell.col
|
||||
const isClickable = cell.status !== 'empty' && onCellClick
|
||||
const blockNumber = getCellBlockNumber(cell, grid)
|
||||
const isHighlighted = highlightedBlockNumber === blockNumber
|
||||
|
||||
// Skip empty cells if not showing them
|
||||
if (!showEmpty && cell.status === 'empty') {
|
||||
return null
|
||||
}
|
||||
@@ -170,7 +321,6 @@ export function GridOverlay({
|
||||
onClick={() => handleCellClick(cell)}
|
||||
className={isClickable ? 'cursor-pointer' : ''}
|
||||
>
|
||||
{/* Cell rectangle */}
|
||||
<rect
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
@@ -186,7 +336,6 @@ export function GridOverlay({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Block number badge */}
|
||||
{showNumbers && cell.status !== 'empty' && (
|
||||
<>
|
||||
<rect
|
||||
@@ -211,8 +360,7 @@ export function GridOverlay({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status indicator dot (only when not showing numbers) */}
|
||||
{!showNumbers && cell.status !== 'empty' && (
|
||||
{!showNumbers && !showTextLabels && cell.status !== 'empty' && (
|
||||
<circle
|
||||
cx={cell.x + 0.8}
|
||||
cy={cell.y + 0.8}
|
||||
@@ -223,7 +371,20 @@ export function GridOverlay({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confidence indicator (for recognized cells) */}
|
||||
{showTextLabels && (cell.status === 'recognized' || cell.status === 'manual') && cell.text && (
|
||||
<text
|
||||
x={cell.x + cell.width / 2}
|
||||
y={cell.y + cell.height / 2 + Math.min(cell.height * 0.2, 0.5)}
|
||||
textAnchor="middle"
|
||||
fontSize={Math.min(cell.height * 0.5, 1.4)}
|
||||
fill={cell.status === 'manual' ? '#1e40af' : '#166534'}
|
||||
fontWeight="500"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{cell.text.length > 15 ? cell.text.slice(0, 15) + '\u2026' : cell.text}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{cell.status === 'recognized' && cell.confidence < 0.7 && (
|
||||
<text
|
||||
x={cell.x + cell.width - 0.5}
|
||||
@@ -236,7 +397,6 @@ export function GridOverlay({
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Selection highlight */}
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={cell.x}
|
||||
@@ -254,7 +414,26 @@ export function GridOverlay({
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Row boundaries (optional grid lines) */}
|
||||
{/* Show cell outlines when in positioned text mode */}
|
||||
{showTextAtPosition && flatCells.map((cell) => {
|
||||
if (cell.status === 'empty') return null
|
||||
return (
|
||||
<rect
|
||||
key={`outline-${cell.row}-${cell.col}`}
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={cell.width}
|
||||
height={cell.height}
|
||||
fill="none"
|
||||
stroke="rgba(99, 102, 241, 0.2)"
|
||||
strokeWidth={0.08}
|
||||
rx={0.1}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Row boundaries */}
|
||||
{grid.row_boundaries.map((y, idx) => (
|
||||
<line
|
||||
key={`row-line-${idx}`}
|
||||
@@ -282,22 +461,30 @@ export function GridOverlay({
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Positioned text HTML overlay (outside SVG for proper text rendering) */}
|
||||
{showTextAtPosition && (
|
||||
<PositionedTextLayer
|
||||
cells={flatCells.filter(c => c.status !== 'empty' && c.text)}
|
||||
editable={editableText}
|
||||
onTextChange={onCellTextChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GridStats Component
|
||||
*
|
||||
* Displays statistics about the grid detection results.
|
||||
*/
|
||||
interface GridStatsProps {
|
||||
stats: GridData['stats']
|
||||
deskewAngle?: number
|
||||
source?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GridStats({ stats, deskewAngle, className }: GridStatsProps) {
|
||||
export function GridStats({ stats, deskewAngle, source, className }: GridStatsProps) {
|
||||
const coveragePercent = Math.round(stats.coverage * 100)
|
||||
|
||||
return (
|
||||
@@ -326,6 +513,11 @@ export function GridStats({ stats, deskewAngle, className }: GridStatsProps) {
|
||||
Begradigt: {deskewAngle.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
{source && (
|
||||
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
|
||||
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user