feat(iace-frontend): expandable detail rows for missing + extra benchmark findings
CI / nodejs-build (push) Successful in 2m21s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 12s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped

The "Zugeordnet" tab already expanded to a GT-vs-Engine detail comparison; the
"Fehlend" and "Engine Findings" tabs were flat and could not be inspected.
Extracted GTDetailBlock / EngineDetailBlock from DetailComparison and made both
tables expandable (chevron) — missing rows show the full GT entry, extra rows
show the full engine hazard (incl. measures, norms, clarification status).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-09 18:43:43 +02:00
parent 2677bca9ca
commit 2a25b66a2f
@@ -87,7 +87,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{tab === 'matched' && <MatchedTable pairs={realMatched} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />} {tab === 'matched' && <MatchedTable pairs={realMatched} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />}
{tab === 'missing' && <MissingTable entries={allMissing} />} {tab === 'missing' && <MissingTable entries={allMissing} />}
{tab === 'extra' && <ExtraTable entries={allExtra} />} {tab === 'extra' && <ExtraTable entries={allExtra} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />}
</div> </div>
</div> </div>
) )
@@ -175,16 +175,17 @@ function formatLifecycles(raw: string): string {
return raw.split(',').map(s => s.trim()).map(s => LIFECYCLE_LABELS[s] || s).join(', ') return raw.split(',').map(s => s.trim()).map(s => LIFECYCLE_LABELS[s] || s).join(', ')
} }
/** Side-by-side detail comparison of GT entry vs. Engine hazard */ function Chevron({ open }: { open: boolean }) {
function DetailComparison({ gt, engine, clarStatus, projectId }: { return (
gt: GroundTruthEntry <svg className={`w-3 h-3 text-gray-400 transition-transform ${open ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
engine: HazardSummary <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
clarStatus?: HazardClarStatus </svg>
projectId?: string )
}) { }
/** Ground Truth (professional) detail block — reused by matched + missing rows. */
function GTDetailBlock({ gt }: { gt: GroundTruthEntry }) {
return ( return (
<div className="grid grid-cols-2 gap-4 text-xs">
{/* Left: Ground Truth */}
<div className="space-y-2"> <div className="space-y-2">
<div className="font-semibold text-red-700 dark:text-red-400 uppercase text-[10px]">Ground Truth (Fachmann)</div> <div className="font-semibold text-red-700 dark:text-red-400 uppercase text-[10px]">Ground Truth (Fachmann)</div>
<DetailRow label="Gefaehrdung" gt={gt.hazard_type} /> <DetailRow label="Gefaehrdung" gt={gt.hazard_type} />
@@ -203,7 +204,14 @@ function DetailComparison({ gt, engine, clarStatus, projectId }: {
<DetailRow label="Hinreichend" gt={gt.sufficient ? 'JA' : 'NEIN'} /> <DetailRow label="Hinreichend" gt={gt.sufficient ? 'JA' : 'NEIN'} />
{gt.comment && <DetailRow label="Kommentar" gt={gt.comment} />} {gt.comment && <DetailRow label="Kommentar" gt={gt.comment} />}
</div> </div>
{/* Right: Engine */} )
}
/** Engine (automatic) detail block — reused by matched + extra rows. */
function EngineDetailBlock({ engine, clarStatus, projectId }: {
engine: HazardSummary; clarStatus?: HazardClarStatus; projectId?: string
}) {
return (
<div className="space-y-2"> <div className="space-y-2">
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div> <div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
<DetailRow label="Gefaehrdung" gt={engine.name} /> <DetailRow label="Gefaehrdung" gt={engine.name} />
@@ -231,6 +239,20 @@ function DetailComparison({ gt, engine, clarStatus, projectId }: {
return <DetailRow label="Referenzierte Normen" gt={norms.join(' | ')} /> return <DetailRow label="Referenzierte Normen" gt={norms.join(' | ')} />
})()} })()}
</div> </div>
)
}
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
function DetailComparison({ gt, engine, clarStatus, projectId }: {
gt: GroundTruthEntry
engine: HazardSummary
clarStatus?: HazardClarStatus
projectId?: string
}) {
return (
<div className="grid grid-cols-2 gap-4 text-xs">
<GTDetailBlock gt={gt} />
<EngineDetailBlock engine={engine} clarStatus={clarStatus} projectId={projectId} />
</div> </div>
) )
} }
@@ -310,6 +332,7 @@ function DetailRow({ label, gt, multiline }: { label: string; gt: string; multil
} }
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) { function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" /> if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
return ( return (
<table className="w-full text-xs"> <table className="w-full text-xs">
@@ -324,22 +347,37 @@ function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700"> <tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((e, i) => ( {entries.map((e, i) => {
<tr key={i} className="hover:bg-red-50/50"> const isOpen = expanded[i]
<td className="px-3 py-2 text-gray-400">{e.nr}</td> return (
<React.Fragment key={i}>
<tr className="hover:bg-red-50/50 cursor-pointer" onClick={() => setExpanded(p => ({ ...p, [i]: !p[i] }))}>
<td className="px-3 py-2 text-gray-400">
<div className="flex items-center gap-1"><Chevron open={isOpen} />{e.nr}</div>
</td>
<td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td> <td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td>
<td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td> <td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td>
<td className="px-3 py-2 text-gray-500">{e.component_zone}</td> <td className="px-3 py-2 text-gray-500">{e.component_zone}</td>
<td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td> <td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td>
<td className="px-3 py-2 text-gray-500">{e.measure_type}</td> <td className="px-3 py-2 text-gray-500">{e.measure_type}</td>
</tr> </tr>
))} {isOpen && (
<tr className="bg-gray-50/70 dark:bg-gray-850">
<td colSpan={6} className="px-4 py-3"><GTDetailBlock gt={e} /></td>
</tr>
)}
</React.Fragment>
)
})}
</tbody> </tbody>
</table> </table>
) )
} }
function ExtraTable({ entries }: { entries: HazardSummary[] }) { function ExtraTable({ entries, clarStatusByHazard, projectId }: {
entries: HazardSummary[]; clarStatusByHazard: Record<string, HazardClarStatus>; projectId?: string
}) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" /> if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" />
return ( return (
<table className="w-full text-xs"> <table className="w-full text-xs">
@@ -351,13 +389,27 @@ function ExtraTable({ entries }: { entries: HazardSummary[] }) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700"> <tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((e, i) => ( {entries.map((e, i) => {
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30"> const isOpen = expanded[i]
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">{e.name}</td> return (
<React.Fragment key={i}>
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer" onClick={() => setExpanded(p => ({ ...p, [i]: !p[i] }))}>
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">
<div className="flex items-center gap-1"><Chevron open={isOpen} />{e.name}</div>
</td>
<td className="px-3 py-2 text-gray-500">{e.category}</td> <td className="px-3 py-2 text-gray-500">{e.category}</td>
<td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td> <td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td>
</tr> </tr>
))} {isOpen && (
<tr className="bg-gray-50/70 dark:bg-gray-850">
<td colSpan={3} className="px-4 py-3">
<EngineDetailBlock engine={e} clarStatus={clarStatusByHazard[e.id]} projectId={projectId} />
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody> </tbody>
</table> </table>
) )