79efa54898
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) <noreply@anthropic.com>
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
'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<string, string> = {
|
|
manufacturer: 'Hersteller',
|
|
pattern_norm: 'Norm / Pattern',
|
|
}
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
open: 'Offen',
|
|
in_progress: 'In Klärung',
|
|
answered: 'Beantwortet',
|
|
not_relevant: 'Nicht relevant',
|
|
}
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
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<ListResponse | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [editing, setEditing] = useState<Clarification | null>(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<string, Clarification[]> = {}
|
|
for (const c of filtered) {
|
|
const key = c.source
|
|
if (!groupedBySource[key]) groupedBySource[key] = []
|
|
groupedBySource[key].push(c)
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto">
|
|
<div className="flex items-baseline justify-between mb-4">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">Klärungen mit dem Anlagenbauer</h1>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
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>
|
|
|
|
<div className="flex gap-3 mb-4 items-center">
|
|
<div className="flex gap-1 text-sm">
|
|
{(['open', 'answered', 'all'] as const).map(f => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFilter(f)}
|
|
className={`px-3 py-1.5 rounded ${filter === f ? 'bg-blue-600 text-white' : 'bg-gray-100 hover:bg-gray-200'}`}
|
|
>
|
|
{f === 'open' ? 'Offen' : f === 'answered' ? 'Beantwortet' : 'Alle'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
type="text"
|
|
placeholder="Suchen in Frage oder Quelle..."
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
className="flex-1 max-w-sm border rounded px-3 py-1.5 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{loading && <div className="text-gray-500">Lade Klärungen…</div>}
|
|
{error && <div className="text-red-600">Fehler: {error}</div>}
|
|
|
|
{!loading && data && Object.keys(groupedBySource).length === 0 && (
|
|
<div className="text-gray-500 italic">Keine Klärungen für die aktuelle Auswahl.</div>
|
|
)}
|
|
|
|
{!loading && data && Object.entries(groupedBySource).map(([source, items]) => (
|
|
<div key={source} className="mb-6">
|
|
<h2 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
|
|
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
|
|
{CATEGORY_LABEL[items[0].category] || items[0].category}
|
|
</span>
|
|
{source}
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{items.map(c => (
|
|
<div key={c.id} className="border rounded-lg p-3 bg-white shadow-sm">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1">
|
|
<div className="text-sm font-medium text-gray-900">{c.question}</div>
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
Betrifft <strong>{c.affected_hazard_ids.length}</strong> Gefährdung
|
|
{c.affected_hazard_ids.length !== 1 ? 'en' : ''}
|
|
{c.affected_hazard_names.length > 0 && (
|
|
<span className="ml-1">— {c.affected_hazard_names.slice(0, 2).join('; ')}{c.affected_hazard_names.length > 2 ? `, +${c.affected_hazard_names.length - 2} weitere` : ''}</span>
|
|
)}
|
|
</div>
|
|
{c.norm_references && c.norm_references.length > 0 && (
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
Norm: {c.norm_references.join(' | ')}
|
|
</div>
|
|
)}
|
|
{c.status === 'answered' && c.reasoning && (
|
|
<div className="mt-2 text-xs text-gray-700 bg-green-50 border border-green-200 rounded p-2">
|
|
<strong>Antwort ({c.answer}):</strong> {c.reasoning}
|
|
{c.answered_by && (
|
|
<span className="text-gray-500 ml-2">— {c.answered_by}, {c.answered_at?.slice(0, 10)}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 text-xs">
|
|
<span className={`px-2 py-0.5 rounded ${STATUS_COLOR[c.status]}`}>{STATUS_LABEL[c.status]}</span>
|
|
<button
|
|
onClick={() => setEditing(c)}
|
|
className="px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
>
|
|
{c.status === 'answered' ? 'Bearbeiten' : 'Beantworten'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{editing && (
|
|
<AnswerModal
|
|
clarification={editing}
|
|
projectId={projectId}
|
|
onClose={() => setEditing(null)}
|
|
onSaved={() => {
|
|
setEditing(null)
|
|
load()
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Badge({ color, label }: { color: string; label: string }) {
|
|
return <span className={`px-2 py-0.5 rounded text-xs ${color}`}>{label}</span>
|
|
}
|
|
|
|
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<string | null>(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 (
|
|
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
|
|
<div className="bg-white rounded-lg max-w-xl w-full p-5 shadow-xl" onClick={e => e.stopPropagation()}>
|
|
<div className="text-sm text-gray-500 mb-1">{clarification.source}</div>
|
|
<div className="text-base font-medium mb-4">{clarification.question}</div>
|
|
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
|
|
<div className="flex gap-1 mb-3 text-sm">
|
|
{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setStatus(s)}
|
|
className={`px-3 py-1 rounded border ${status === s ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
|
|
>
|
|
{STATUS_LABEL[s]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{(status === 'answered' || status === 'in_progress') && (
|
|
<>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">Antwort</label>
|
|
<div className="flex gap-1 mb-3 text-sm">
|
|
{(['ja', 'teilweise', 'nein'] as const).map(a => (
|
|
<button
|
|
key={a}
|
|
onClick={() => setAnswer(a)}
|
|
className={`px-3 py-1 rounded border ${answer === a ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
|
|
>
|
|
{a}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">Begründung / Notiz</label>
|
|
<textarea
|
|
value={reasoning}
|
|
onChange={e => setReasoning(e.target.value)}
|
|
rows={4}
|
|
className="w-full border rounded p-2 text-sm mb-3"
|
|
placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei."
|
|
/>
|
|
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
|
|
<input
|
|
type="text"
|
|
value={answeredBy}
|
|
onChange={e => setAnsweredBy(e.target.value)}
|
|
className="w-full border rounded p-2 text-sm mb-4"
|
|
placeholder="Name oder Kürzel"
|
|
/>
|
|
|
|
{error && <div className="text-red-600 text-sm mb-2">Fehler: {error}</div>}
|
|
|
|
<div className="flex justify-end gap-2 text-sm">
|
|
<button onClick={onClose} className="px-3 py-1.5 rounded border bg-white hover:bg-gray-50">Abbrechen</button>
|
|
<button onClick={save} disabled={saving} className="px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
|
|
{saving ? 'Speichere…' : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|