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:
+88
-19
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user