From 81a05685378a82716efa377bf456810f590076db Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 13 May 2026 15:00:19 +0200 Subject: [PATCH] feat(iace): block-aware risk table + benchmark quality badges Risk Assessment tab now shows block grouping: - BlockAwareRiskTable: Parents bold/purple, children indented - Collapse/expand blocks, "Abgedeckt" badge for covered children - Ungroup button to remove child from block - Info bar showing block count and covered children Benchmark tab improvements: - Green/Yellow/Red quality badges (Exakt/Aehnlich/Schwach) - GT risk factor detail (F/W/P/S) shown per entry - Match counts in tab header (X exakt, Y aehnlich) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../_components/HazardComparisonTable.tsx | 72 ++++-- .../_components/BlockAwareRiskTable.tsx | 237 ++++++++++++++++++ .../app/sdk/iace/[projectId]/hazards/page.tsx | 3 +- 3 files changed, 290 insertions(+), 22 deletions(-) create mode 100644 admin-compliance/app/sdk/iace/[projectId]/hazards/_components/BlockAwareRiskTable.tsx diff --git a/admin-compliance/app/sdk/iace/[projectId]/benchmark/_components/HazardComparisonTable.tsx b/admin-compliance/app/sdk/iace/[projectId]/benchmark/_components/HazardComparisonTable.tsx index 4e0400f..dfa8417 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/benchmark/_components/HazardComparisonTable.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/benchmark/_components/HazardComparisonTable.tsx @@ -14,8 +14,12 @@ type TabType = 'matched' | 'missing' | 'extra' export function HazardComparisonTable({ matched, missing, extra }: Props) { const [tab, setTab] = useState('matched') + // Compute quality levels for matched pairs + const greenCount = matched.filter(p => p.match_score >= 0.7).length + const yellowCount = matched.filter(p => p.match_score >= 0.4 && p.match_score < 0.7).length + const tabs: { id: TabType; label: string; count: number; color: string }[] = [ - { id: 'matched', label: 'Zugeordnet', count: matched.length, color: 'text-green-600' }, + { id: 'matched', label: `Zugeordnet (${greenCount} exakt, ${yellowCount} aehnlich)`, count: matched.length, color: 'text-green-600' }, { id: 'missing', label: 'Fehlend', count: missing.length, color: 'text-red-600' }, { id: 'extra', label: 'Zusaetzlich', count: extra.length, color: 'text-gray-500' }, ] @@ -63,26 +67,38 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) { - {pairs.map((p, i) => ( - - {p.gt_entry.nr} - -
{p.gt_entry.hazard_type}
-
{p.gt_entry.component_zone}
- - - - - -
{p.engine_hazard.name}
-
{p.engine_hazard.category}
- - - - - {p.match_reason} - - ))} + {pairs.map((p, i) => { + const quality = p.match_score >= 0.7 ? 'green' : p.match_score >= 0.4 ? 'yellow' : 'red' + const rowBg = quality === 'green' ? 'bg-green-50/30 dark:bg-green-900/5' + : quality === 'yellow' ? 'bg-yellow-50/30 dark:bg-yellow-900/5' : '' + const gtRiskLevel = p.gt_entry.risk_in.r >= 30 ? 'hoch' : p.gt_entry.risk_in.r >= 15 ? 'mittel' : 'niedrig' + return ( + + {p.gt_entry.nr} + +
{p.gt_entry.hazard_type}
+
{p.gt_entry.component_zone}
+ + + +
+ F{p.gt_entry.risk_in.f} W{p.gt_entry.risk_in.w} P{p.gt_entry.risk_in.p} S{p.gt_entry.risk_in.s} +
+ + +
{p.engine_hazard.name}
+
{p.engine_hazard.category}
+ + + + + + +
{p.match_reason}
+ + + ) + })} ) @@ -153,6 +169,20 @@ function ScoreBadge({ score }: { score: number }) { return {pct}% } +function QualityBadge({ quality }: { quality: 'green' | 'yellow' | 'red' }) { + const styles = { + green: 'bg-green-100 text-green-700 border-green-200', + yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200', + red: 'bg-red-100 text-red-700 border-red-200', + } + const labels = { green: 'Exakt', yellow: 'Aehnlich', red: 'Schwach' } + return ( + + {labels[quality]} + + ) +} + function EmptyState({ text }: { text: string }) { return
{text}
} diff --git a/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/BlockAwareRiskTable.tsx b/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/BlockAwareRiskTable.tsx new file mode 100644 index 0000000..43d52df --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/hazards/_components/BlockAwareRiskTable.tsx @@ -0,0 +1,237 @@ +'use client' + +import React, { useState, useEffect, useMemo } from 'react' +import { Hazard } from './types' +import { RiskAssessmentTable } from './RiskAssessmentTable' + +interface BlockData { + parent_hazard: { hazard: { id: string } } + children: { hazard: { id: string } }[] + children_covered_by_parent: boolean + block_key: string +} + +interface BlockInfo { + isParent: boolean + isChild: boolean + isCovered: boolean + blockKey: string + parentId: string + childCount: number +} + +interface Props { + projectId: string + hazards: Hazard[] + onReassess?: () => void + decisions?: Record + onDecision?: (hazardId: string, acceptable: boolean | null) => void +} + +/** + * Wraps RiskAssessmentTable with block-awareness: + * - Injects block metadata into hazards so the table can show grouping + * - Provides controls to ungroup/promote children + */ +export function BlockAwareRiskTable({ projectId, hazards, onReassess, decisions, onDecision }: Props) { + const [blocks, setBlocks] = useState([]) + const [collapsed, setCollapsed] = useState>({}) + const [ungrouped, setUngrouped] = useState>({}) + + useEffect(() => { + fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`) + .then(r => r.ok ? r.json() : null) + .then(d => { if (d?.blocks) setBlocks(d.blocks) }) + .catch(() => {}) + }, [projectId]) + + // Build lookup: hazardId → block info + const blockMap = useMemo(() => { + const map: Record = {} + for (const b of blocks) { + if (b.children.length === 0) continue + const pid = b.parent_hazard.hazard.id + map[pid] = { + isParent: true, isChild: false, isCovered: false, + blockKey: b.block_key, parentId: pid, childCount: b.children.length, + } + for (const c of b.children) { + if (ungrouped[c.hazard.id]) continue + map[c.hazard.id] = { + isParent: false, isChild: true, + isCovered: b.children_covered_by_parent, + blockKey: b.block_key, parentId: pid, childCount: 0, + } + } + } + return map + }, [blocks, ungrouped]) + + // Sort hazards: parents first, then their children, then standalone + const sortedHazards = useMemo(() => { + const parents: Hazard[] = [] + const childrenByParent: Record = {} + const standalone: Hazard[] = [] + + for (const h of hazards) { + const info = blockMap[h.id] + if (!info) { + standalone.push(h) + } else if (info.isParent) { + parents.push(h) + childrenByParent[h.id] = [] + } else if (info.isChild) { + const arr = childrenByParent[info.parentId] + if (arr) arr.push(h) + else standalone.push(h) + } + } + + // Sort parents by risk desc + parents.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0)) + standalone.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0)) + + // Interleave: parent → children → parent → children → ... → standalone + const result: Hazard[] = [] + for (const p of parents) { + result.push(p) + const isCollapsed = collapsed[p.id] + if (!isCollapsed && childrenByParent[p.id]) { + result.push(...childrenByParent[p.id]) + } + } + result.push(...standalone) + return result + }, [hazards, blockMap, collapsed]) + + const toggleCollapse = (parentId: string) => { + setCollapsed(prev => ({ ...prev, [parentId]: !prev[parentId] })) + } + + const handleUngroup = (childId: string) => { + setUngrouped(prev => ({ ...prev, [childId]: true })) + } + + // Count blocks with children + const blockCount = blocks.filter(b => b.children.length > 0).length + const coveredCount = Object.values(blockMap).filter(b => b.isChild && b.isCovered).length + + return ( +
+ {/* Block info bar */} + {blockCount > 0 && ( +
+ + {blockCount} Bloecke erkannt + + {coveredCount > 0 && ( + + {coveredCount} Kinder durch Mutter abgedeckt + + )} + + Klick auf Block-Icon zum Auf-/Zuklappen. Rechtsklick oder Aktion zum Entgruppieren. + +
+ )} + + {/* Enhanced table with block decorations */} +
+
+ + + + + + + + + + + {sortedHazards.map(h => { + const info = blockMap[h.id] + const isParent = info?.isParent + const isChild = info?.isChild + const isCovered = info?.isCovered + const childCount = info?.childCount || 0 + const isCollapsedParent = isParent && collapsed[h.id] + + return ( + + {/* Block indicator */} + + {/* Name */} + + {/* Category */} + + {/* Risk */} + + + + + {/* Status */} + + + ) + })} + +
GefaehrdungRisiko (S x F x P)Status
+ {isParent && ( + + )} + {isChild && ( +
+
+ +
+ )} +
+
+ {h.name} + {isParent && ({childCount})} +
+ {h.hazardous_zone &&
{h.hazardous_zone}
} +
+ {h.category?.replace(/_/g, ' ')} + {h.severity || '-'}{h.exposure || '-'}{h.probability || '-'} + {h.r_inherent || '-'} + + {isCovered ? ( + + + + + Abgedeckt + + ) : h.r_inherent ? ( + + {(h.r_inherent || 0) <= 20 ? 'Niedrig' : (h.r_inherent || 0) <= 60 ? 'Mittel' : 'Hoch'} + + ) : ( + Offen + )} +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx index a458260..8bac01b 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx @@ -5,6 +5,7 @@ import { useParams } from 'next/navigation' import { HazardForm } from './_components/HazardForm' import { HazardTable } from './_components/HazardTable' import { HazardBlockView } from './_components/HazardBlockView' +import { BlockAwareRiskTable } from './_components/BlockAwareRiskTable' import { RiskAssessmentTable } from './_components/RiskAssessmentTable' import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel' import type { ResidualFilter } from './_components/ResidualRiskPanel' @@ -174,7 +175,7 @@ export default function HazardsPage() { <> - ) : view === 'blocks' ? (