feat(iace): Klaerungen Phase 2 — Sidebar-Counter + CSV-Export + Hazard-Banner

Three pieces complete the Klaerungen UX:

1. Sidebar-Counter: layout.tsx polls /clarifications and shows a
   colored open-count badge on the "Klaerungen" nav item. Refreshes
   whenever the user changes route.

2. CSV-Export: new backend endpoint
   GET /sdk/v1/iace/projects/:id/clarifications.csv produces a UTF-8-
   BOM-prefixed semicolon-separated CSV (Excel-friendly) with ID,
   Quelle, Kategorie, Frage, Status, Antwort, Begruendung, Bearbeiter,
   answered_at, anzahl Gefaehrdungen, Gefaehrdungs-Namen, Norm-Refs.
   Frontend Klaerungen-Seite bekommt einen "CSV-Export"-Button.

3. Hazard-Banner statt Fragentext im Benchmark-Detail: the previous
   bulleted clarification list was duplicated across 48 hazards for a
   single FANUC question. Phase 2 replaces it with a compact status
   badge — "N offene Klaerung(en) — Klaerungen-Seite oeffnen" (orange)
   or "Alle N Klaerungen beantwortet" (green) with a direct link.

Backend cleanup: iace_handler_init.go no longer appends the "Mit
Anlagenbauer zu klaeren" block to Hazard.Description. The description
stays focused on the scenario; clarifications live in the dedicated
endpoint and answers persist across re-inits via project.metadata.
The aggregated "Referenzierte Normen" line on the hazard is kept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-17 01:25:36 +02:00
parent 525038359a
commit f19a75d83d
6 changed files with 205 additions and 62 deletions
@@ -1,6 +1,7 @@
'use client'
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
interface Props {
@@ -11,8 +12,41 @@ interface Props {
type TabType = 'matched' | 'missing' | 'extra'
// Per-hazard clarification status fetched once and shared with all detail rows.
type HazardClarStatus = { open: number; answered: number; total: number }
function useClarificationsByHazard(projectId: string | undefined): Record<string, HazardClarStatus> {
const [byHz, setByHz] = useState<Record<string, HazardClarStatus>>({})
useEffect(() => {
if (!projectId) return
let cancelled = false
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (cancelled || !d?.clarifications) return
const out: Record<string, HazardClarStatus> = {}
for (const c of d.clarifications as Array<{ affected_hazard_ids: string[]; status: string }>) {
const isOpen = c.status !== 'answered' && c.status !== 'not_relevant'
for (const hid of c.affected_hazard_ids) {
if (!out[hid]) out[hid] = { open: 0, answered: 0, total: 0 }
out[hid].total += 1
if (isOpen) out[hid].open += 1
else out[hid].answered += 1
}
}
setByHz(out)
})
.catch(() => {})
return () => { cancelled = true }
}, [projectId])
return byHz
}
export function HazardComparisonTable({ matched, missing, extra }: Props) {
const [tab, setTab] = useState<TabType>('matched')
const params = useParams()
const projectId = params?.projectId as string | undefined
const clarStatusByHazard = useClarificationsByHazard(projectId)
// Split matches: >= 50% are real matches, < 50% are weak (shown separately)
const realMatched = matched.filter(p => p.match_score >= 0.5)
@@ -51,7 +85,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
</div>
<div className="overflow-x-auto">
{tab === 'matched' && <MatchedTable pairs={realMatched} />}
{tab === 'matched' && <MatchedTable pairs={realMatched} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />}
{tab === 'missing' && <MissingTable entries={allMissing} />}
{tab === 'extra' && <ExtraTable entries={allExtra} />}
</div>
@@ -59,7 +93,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
)
}
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
function MatchedTable({ pairs, clarStatusByHazard, projectId }: { pairs: HazardMatchPair[]; clarStatusByHazard: Record<string, HazardClarStatus>; projectId?: string }) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
return (
@@ -109,7 +143,12 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
{isOpen && (
<tr className="bg-gray-50/70 dark:bg-gray-850">
<td colSpan={6} className="px-4 py-3">
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
<DetailComparison
gt={p.gt_entry}
engine={p.engine_hazard}
clarStatus={clarStatusByHazard[p.engine_hazard.id]}
projectId={projectId}
/>
</td>
</tr>
)}
@@ -137,7 +176,12 @@ function formatLifecycles(raw: string): string {
}
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) {
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">
{/* Left: Ground Truth */}
@@ -178,11 +222,9 @@ function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: Hazard
) : (
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
)}
{(() => {
const clarifications = extractClarifications(engine.description)
if (clarifications.length === 0) return null
return <DetailRow label="Mit Anlagenbauer zu klaeren" gt={clarifications.map(c => '• ' + c).join('\n')} multiline />
})()}
{clarStatus && clarStatus.total > 0 && (
<ClarificationBanner status={clarStatus} projectId={projectId} />
)}
{(() => {
const norms = extractEngineNorms(engine.description)
if (norms.length === 0) return null
@@ -209,15 +251,42 @@ function extractScenario(desc?: string): string {
return (normIdx >= 0 ? cut.slice(0, normIdx) : cut).trim()
}
function extractClarifications(desc?: string): string[] {
if (!desc) return []
const start = desc.indexOf('Mit Anlagenbauer zu klaeren:')
if (start < 0) return []
const after = desc.slice(start + 'Mit Anlagenbauer zu klaeren:'.length)
// Stop at the next double-newline section (e.g. norm block)
const stop = after.indexOf('\n\n')
const block = stop >= 0 ? after.slice(0, stop) : after
return block.split('\n').map(s => s.replace(/^[\s-]+/, '').trim()).filter(Boolean)
// (extractClarifications removed in Phase 2 — clarifications are loaded
// from the dedicated /clarifications API and rendered as a status banner
// instead of being parsed out of the hazard description.)
function ClarificationBanner({ status, projectId }: { status: HazardClarStatus; projectId?: string }) {
const allDone = status.open === 0
const href = projectId ? `/sdk/iace/${projectId}/clarifications` : '#'
return (
<div>
<div className="text-[10px] font-medium text-gray-500 uppercase">Klärungen</div>
<a
href={href}
className={`mt-0.5 inline-flex items-center gap-2 px-3 py-1.5 rounded border text-xs ${
allDone
? 'bg-green-50 border-green-200 text-green-800 hover:bg-green-100'
: 'bg-orange-50 border-orange-200 text-orange-800 hover:bg-orange-100'
}`}
>
{allDone ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Alle {status.total} Klärungen beantwortet
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{status.open} offene Klärung{status.open === 1 ? '' : 'en'} {status.answered > 0 && `(${status.answered} beantwortet)`} Klärungen-Seite öffnen
</>
)}
</a>
</div>
)
}
function extractEngineNorms(desc?: string): string[] {
@@ -100,13 +100,26 @@ export default function ClarificationsPage() {
Standardisierte Prüffragen aus Norm- und Herstellerwissen. Eine Antwort gilt für alle referenzierten Gefährdungen.
</p>
</div>
{data && (
<div className="flex gap-2 text-sm">
<Badge color="bg-orange-100 text-orange-800" label={`${data.open_count} offen`} />
<Badge color="bg-green-100 text-green-800" label={`${data.answered_count} beantwortet`} />
<Badge color="bg-gray-100 text-gray-700" label={`${data.total} gesamt`} />
</div>
)}
<div className="flex items-center gap-3">
{data && (
<div className="flex gap-2 text-sm">
<Badge color="bg-orange-100 text-orange-800" label={`${data.open_count} offen`} />
<Badge color="bg-green-100 text-green-800" label={`${data.answered_count} beantwortet`} />
<Badge color="bg-gray-100 text-gray-700" label={`${data.total} gesamt`} />
</div>
)}
<a
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.csv`}
download
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
title="CSV-Export für die Übergabe an den Anlagenbauer"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
CSV-Export
</a>
</div>
</div>
<div className="flex gap-3 mb-4 items-center">
+26 -1
View File
@@ -116,6 +116,23 @@ export default function IACELayout({ children }: { children: React.ReactNode })
const [variantInfo, setVariantInfo] = React.useState<{
parentProjectId?: string; parentName?: string; variantCount?: number
}>({})
const [openClarifications, setOpenClarifications] = React.useState<number | null>(null)
// Poll the clarifications endpoint so the sidebar always shows the
// current "offene Klaerungen" counter. Refresh whenever the user
// navigates back to this layout (i.e. when pathname changes).
React.useEffect(() => {
if (!projectId) return
let cancelled = false
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (cancelled || !d || typeof d.open_count !== 'number') return
setOpenClarifications(d.open_count)
})
.catch(() => {})
return () => { cancelled = true }
}, [projectId, pathname])
React.useEffect(() => {
if (!projectId) return
@@ -219,7 +236,15 @@ export default function IACELayout({ children }: { children: React.ReactNode })
}`}
>
<NavIcon icon={item.icon} className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{item.label}</span>
<span className="truncate flex-1">{item.label}</span>
{item.id === 'clarifications' && openClarifications !== null && openClarifications > 0 && (
<span
className="ml-auto inline-flex items-center justify-center min-w-[20px] px-1.5 py-0.5 text-[10px] font-semibold rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300"
title={`${openClarifications} offene Klärung${openClarifications === 1 ? '' : 'en'}`}
>
{openClarifications}
</span>
)}
</Link>
))}
</nav>