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:
BreakPilot Dev
2026-02-10 00:01:04 +01:00
parent 53219e3eaf
commit dff2ef796b
94 changed files with 29706 additions and 1039 deletions

View File

@@ -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>
)
}