From 79efa548983279410979d76bbd1297816fdb093e Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 17 May 2026 01:05:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(iace):=20Klaerungen=20MVP=20=E2=80=94=20Ph?= =?UTF-8?q?ase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New page "Klaerungen" between Massnahmen and Verifikation. Backend: - internal/iace/clarifications.go: Clarification struct + ClarificationAnswer + BuildProjectClarifications() — aggregates pattern-level + manufacturer- level questions from collectAllPatterns + GetManufacturerSafetyFeatures. Deterministic IDs ("pattern:HP1640:0", "manuf:fanuc:dual-check-safety-dcs:1") so persisted answers survive every re-init. - internal/api/handlers/iace_handler_clarifications.go: - GET /projects/:id/clarifications returns aggregated list with affected hazard names + persisted answer state, sorted (open first). - POST /projects/:id/clarifications/:cid/answer writes status/answer/ reasoning/answered_by/answered_at to project.metadata.clarification_- answers — no DB schema change. Frontend: - admin-compliance/app/sdk/iace/layout.tsx: new "Klaerungen" nav item. - app/sdk/iace/[projectId]/clarifications/page.tsx: table grouped by source (FANUC / Pattern HP1640 / …), Filter Offen/Beantwortet/Alle, search field, Antwort-Modal with status/answer/Begruendung/Bearbeiter. A clarification answered once applies to ALL referenced hazards — the operator no longer has to answer the same FANUC DCS question on 48 mechanical hazards individually. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../iace/[projectId]/clarifications/page.tsx | 316 ++++++++++++++++++ admin-compliance/app/sdk/iace/layout.tsx | 1 + .../handlers/iace_handler_clarifications.go | 218 ++++++++++++ ai-compliance-sdk/internal/app/routes.go | 4 + .../internal/iace/clarifications.go | 240 +++++++++++++ 5 files changed, 779 insertions(+) create mode 100644 admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go create mode 100644 ai-compliance-sdk/internal/iace/clarifications.go diff --git a/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx new file mode 100644 index 00000000..685396f4 --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/clarifications/page.tsx @@ -0,0 +1,316 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useParams } from 'next/navigation' + +type Clarification = { + id: string + question: string + source: string + category: 'manufacturer' | 'pattern_norm' | string + norm_references?: string[] + affected_hazard_ids: string[] + affected_hazard_names: string[] + status: 'open' | 'in_progress' | 'answered' | 'not_relevant' + answer?: 'ja' | 'nein' | 'teilweise' | '' + reasoning?: string + answered_by?: string + answered_at?: string +} + +type ListResponse = { + clarifications: Clarification[] + open_count: number + answered_count: number + total: number +} + +const CATEGORY_LABEL: Record = { + manufacturer: 'Hersteller', + pattern_norm: 'Norm / Pattern', +} + +const STATUS_LABEL: Record = { + open: 'Offen', + in_progress: 'In Klärung', + answered: 'Beantwortet', + not_relevant: 'Nicht relevant', +} + +const STATUS_COLOR: Record = { + open: 'bg-orange-100 text-orange-800', + in_progress: 'bg-yellow-100 text-yellow-800', + answered: 'bg-green-100 text-green-800', + not_relevant: 'bg-gray-100 text-gray-700', +} + +export default function ClarificationsPage() { + const params = useParams() + const projectId = params.projectId as string + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [editing, setEditing] = useState(null) + const [filter, setFilter] = useState<'all' | 'open' | 'answered'>('open') + const [searchQuery, setSearchQuery] = useState('') + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const json: ListResponse = await r.json() + setData(json) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [projectId]) + + useEffect(() => { + load() + }, [load]) + + const filtered = (data?.clarifications ?? []).filter(c => { + if (filter === 'open' && (c.status === 'answered' || c.status === 'not_relevant')) return false + if (filter === 'answered' && c.status !== 'answered' && c.status !== 'not_relevant') return false + if (searchQuery) { + const q = searchQuery.toLowerCase() + if (!c.question.toLowerCase().includes(q) && !c.source.toLowerCase().includes(q)) return false + } + return true + }) + + const groupedBySource: Record = {} + for (const c of filtered) { + const key = c.source + if (!groupedBySource[key]) groupedBySource[key] = [] + groupedBySource[key].push(c) + } + + return ( +
+
+
+

Klärungen mit dem Anlagenbauer

+

+ Standardisierte Prüffragen aus Norm- und Herstellerwissen. Eine Antwort gilt für alle referenzierten Gefährdungen. +

+
+ {data && ( +
+ + + +
+ )} +
+ +
+
+ {(['open', 'answered', 'all'] as const).map(f => ( + + ))} +
+ setSearchQuery(e.target.value)} + className="flex-1 max-w-sm border rounded px-3 py-1.5 text-sm" + /> +
+ + {loading &&
Lade Klärungen…
} + {error &&
Fehler: {error}
} + + {!loading && data && Object.keys(groupedBySource).length === 0 && ( +
Keine Klärungen für die aktuelle Auswahl.
+ )} + + {!loading && data && Object.entries(groupedBySource).map(([source, items]) => ( +
+

+ + {CATEGORY_LABEL[items[0].category] || items[0].category} + + {source} +

+
+ {items.map(c => ( +
+
+
+
{c.question}
+
+ Betrifft {c.affected_hazard_ids.length} Gefährdung + {c.affected_hazard_ids.length !== 1 ? 'en' : ''} + {c.affected_hazard_names.length > 0 && ( + — {c.affected_hazard_names.slice(0, 2).join('; ')}{c.affected_hazard_names.length > 2 ? `, +${c.affected_hazard_names.length - 2} weitere` : ''} + )} +
+ {c.norm_references && c.norm_references.length > 0 && ( +
+ Norm: {c.norm_references.join(' | ')} +
+ )} + {c.status === 'answered' && c.reasoning && ( +
+ Antwort ({c.answer}): {c.reasoning} + {c.answered_by && ( + — {c.answered_by}, {c.answered_at?.slice(0, 10)} + )} +
+ )} +
+
+ {STATUS_LABEL[c.status]} + +
+
+
+ ))} +
+
+ ))} + + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null) + load() + }} + /> + )} +
+ ) +} + +function Badge({ color, label }: { color: string; label: string }) { + return {label} +} + +function AnswerModal({ + clarification, + projectId, + onClose, + onSaved, +}: { + clarification: Clarification + projectId: string + onClose: () => void + onSaved: () => void +}) { + const [status, setStatus] = useState(clarification.status) + const [answer, setAnswer] = useState<'ja' | 'nein' | 'teilweise' | ''>( + (clarification.answer as 'ja' | 'nein' | 'teilweise' | '') || '' + ) + const [reasoning, setReasoning] = useState(clarification.reasoning || '') + const [answeredBy, setAnsweredBy] = useState(clarification.answered_by || '') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const save = async () => { + setSaving(true) + setError(null) + try { + const r = await fetch( + `/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/answer`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, answer, reasoning, answered_by: answeredBy }), + } + ) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + onSaved() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + return ( +
+
e.stopPropagation()}> +
{clarification.source}
+
{clarification.question}
+ + +
+ {(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => ( + + ))} +
+ + {(status === 'answered' || status === 'in_progress') && ( + <> + +
+ {(['ja', 'teilweise', 'nein'] as const).map(a => ( + + ))} +
+ + )} + + +