All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 34s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 19s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
Phase 5 — Frontend Integration: - components/page.tsx: ComponentLibraryModal with 120 components + 20 energy sources - hazards/page.tsx: AutoSuggestPanel with 3-column pattern matching review - mitigations/page.tsx: SuggestMeasuresModal per hazard with 3-level grouping - verification/page.tsx: SuggestEvidenceModal per mitigation with evidence types Phase 6 — RAG Library Search: - Added bp_iace_libraries to AllowedCollections whitelist in rag_handlers.go - SearchLibrary endpoint: POST /iace/library-search (semantic search across libraries) - EnrichTechFileSection endpoint: POST /projects/:id/tech-file/:section/enrich - Created ingest-iace-libraries.sh ingestion script for Qdrant collection Tests (123 passing): - tag_taxonomy_test.go: 8 tests for taxonomy entries, domains, essential tags - controls_library_test.go: 7 tests for measures, reduction types, subtypes - integration_test.go: 7 integration tests for full match flow and library consistency - Extended tag_resolver_test.go: 9 new tests for FindByTags and cross-category resolution Documentation: - Updated iace.md with Hazard-Matching-Engine, RAG enrichment, and new DB tables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
902 lines
35 KiB
TypeScript
902 lines
35 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
import { useParams } from 'next/navigation'
|
|
|
|
interface Mitigation {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
reduction_type: 'design' | 'protection' | 'information'
|
|
status: 'planned' | 'implemented' | 'verified'
|
|
linked_hazard_ids: string[]
|
|
linked_hazard_names: string[]
|
|
created_at: string
|
|
verified_at: string | null
|
|
verified_by: string | null
|
|
source?: string
|
|
}
|
|
|
|
interface Hazard {
|
|
id: string
|
|
name: string
|
|
risk_level: string
|
|
category?: string
|
|
}
|
|
|
|
interface ProtectiveMeasure {
|
|
id: string
|
|
reduction_type: string
|
|
sub_type: string
|
|
name: string
|
|
description: string
|
|
hazard_category: string
|
|
examples: string[]
|
|
}
|
|
|
|
interface SuggestedMeasure {
|
|
id: string
|
|
reduction_type: string
|
|
sub_type: string
|
|
name: string
|
|
description: string
|
|
hazard_category: string
|
|
examples: string[]
|
|
tags?: string[]
|
|
}
|
|
|
|
const REDUCTION_TYPES = {
|
|
design: {
|
|
label: 'Stufe 1: Design',
|
|
description: 'Inhaerent sichere Konstruktion',
|
|
color: 'border-blue-200 bg-blue-50',
|
|
headerColor: 'bg-blue-100 text-blue-800',
|
|
subTypes: [
|
|
{ value: 'geometry', label: 'Geometrie & Anordnung' },
|
|
{ value: 'force_energy', label: 'Kraft & Energie' },
|
|
{ value: 'material', label: 'Material & Stabilitaet' },
|
|
{ value: 'ergonomics', label: 'Ergonomie' },
|
|
{ value: 'control_design', label: 'Steuerungstechnik' },
|
|
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
|
|
],
|
|
icon: (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
|
</svg>
|
|
),
|
|
},
|
|
protection: {
|
|
label: 'Stufe 2: Schutz',
|
|
description: 'Technische Schutzmassnahmen',
|
|
color: 'border-green-200 bg-green-50',
|
|
headerColor: 'bg-green-100 text-green-800',
|
|
subTypes: [
|
|
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
|
|
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
|
|
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
|
|
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
|
|
{ value: 'emergency_stop', label: 'Not-Halt' },
|
|
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
|
|
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
|
|
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
|
|
{ value: 'extraction', label: 'Absaugung / Kapselung' },
|
|
],
|
|
icon: (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
),
|
|
},
|
|
information: {
|
|
label: 'Stufe 3: Information',
|
|
description: 'Hinweise und Schulungen',
|
|
color: 'border-yellow-200 bg-yellow-50',
|
|
headerColor: 'bg-yellow-100 text-yellow-800',
|
|
subTypes: [
|
|
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
|
|
{ value: 'manual', label: 'Betriebsanleitung' },
|
|
{ value: 'training', label: 'Schulung & Unterweisung' },
|
|
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
|
|
{ value: 'organizational', label: 'Organisatorisch' },
|
|
{ value: 'marking', label: 'Markierung & Codierung' },
|
|
],
|
|
icon: (
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
),
|
|
},
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const colors: Record<string, string> = {
|
|
planned: 'bg-gray-100 text-gray-700',
|
|
implemented: 'bg-blue-100 text-blue-700',
|
|
verified: 'bg-green-100 text-green-700',
|
|
}
|
|
const labels: Record<string, string> = {
|
|
planned: 'Geplant',
|
|
implemented: 'Umgesetzt',
|
|
verified: 'Verifiziert',
|
|
}
|
|
return (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
|
|
{labels[status] || status}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
|
|
return (
|
|
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
|
|
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<div className="flex-1">
|
|
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ "Information"</h4>
|
|
<p className="text-sm text-amber-700 mt-1">
|
|
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
|
|
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
|
|
Massnahmen ergaenzt werden koennen.
|
|
</p>
|
|
</div>
|
|
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MeasuresLibraryModal({
|
|
measures,
|
|
onSelect,
|
|
onClose,
|
|
filterType,
|
|
}: {
|
|
measures: ProtectiveMeasure[]
|
|
onSelect: (measure: ProtectiveMeasure) => void
|
|
onClose: () => void
|
|
filterType?: string
|
|
}) {
|
|
const [search, setSearch] = useState('')
|
|
const [selectedSubType, setSelectedSubType] = useState('')
|
|
|
|
const filtered = measures.filter((m) => {
|
|
if (filterType && m.reduction_type !== filterType) return false
|
|
if (selectedSubType && m.sub_type !== selectedSubType) return false
|
|
if (search) {
|
|
const q = search.toLowerCase()
|
|
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
|
|
}
|
|
return true
|
|
})
|
|
|
|
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Bibliothek</h3>
|
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Massnahme suchen..."
|
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
{subTypes.length > 1 && (
|
|
<select
|
|
value={selectedSubType}
|
|
onChange={(e) => setSelectedSubType(e.target.value)}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
|
|
>
|
|
<option value="">Alle Sub-Typen</option>
|
|
{subTypes.map((st) => (
|
|
<option key={st} value={st}>{st}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
|
{filtered.map((m) => (
|
|
<div
|
|
key={m.id}
|
|
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
|
|
onClick={() => onSelect(m)}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
|
{m.sub_type && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
|
)}
|
|
</div>
|
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
|
|
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
|
|
{m.examples && m.examples.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{m.examples.map((ex, i) => (
|
|
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
|
|
{ex}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
|
|
Uebernehmen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Suggest Measures Modal (Phase 5)
|
|
// ============================================================================
|
|
|
|
function SuggestMeasuresModal({
|
|
hazards,
|
|
projectId,
|
|
onAddMeasure,
|
|
onClose,
|
|
}: {
|
|
hazards: Hazard[]
|
|
projectId: string
|
|
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
|
|
onClose: () => void
|
|
}) {
|
|
const [selectedHazard, setSelectedHazard] = useState<string>('')
|
|
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
|
|
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
|
|
|
const riskColors: Record<string, string> = {
|
|
not_acceptable: 'border-red-400 bg-red-50',
|
|
very_high: 'border-red-300 bg-red-50',
|
|
critical: 'border-red-300 bg-red-50',
|
|
high: 'border-orange-300 bg-orange-50',
|
|
medium: 'border-yellow-300 bg-yellow-50',
|
|
low: 'border-green-300 bg-green-50',
|
|
}
|
|
|
|
async function handleSelectHazard(hazardId: string) {
|
|
setSelectedHazard(hazardId)
|
|
setSuggested([])
|
|
if (!hazardId) return
|
|
|
|
setLoadingSuggestions(true)
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
if (res.ok) {
|
|
const json = await res.json()
|
|
setSuggested(json.suggested_measures || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to suggest measures:', err)
|
|
} finally {
|
|
setLoadingSuggestions(false)
|
|
}
|
|
}
|
|
|
|
const groupedByType = {
|
|
design: suggested.filter(m => m.reduction_type === 'design'),
|
|
protection: suggested.filter(m => m.reduction_type === 'protection'),
|
|
information: suggested.filter(m => m.reduction_type === 'information'),
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Vorschlaege</h3>
|
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-gray-500 mb-3">
|
|
Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{hazards.map(h => (
|
|
<button
|
|
key={h.id}
|
|
onClick={() => handleSelectHazard(h.id)}
|
|
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
|
selectedHazard === h.id
|
|
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
|
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
|
|
}`}
|
|
>
|
|
{h.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-6">
|
|
{loadingSuggestions ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
) : suggested.length > 0 ? (
|
|
<div className="space-y-6">
|
|
{(['design', 'protection', 'information'] as const).map(type => {
|
|
const items = groupedByType[type]
|
|
if (items.length === 0) return null
|
|
const config = REDUCTION_TYPES[type]
|
|
return (
|
|
<div key={type}>
|
|
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
|
{config.icon}
|
|
<span className="text-sm font-semibold">{config.label}</span>
|
|
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{items.map(m => (
|
|
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
|
{m.sub_type && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
|
|
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
|
|
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
|
>
|
|
Uebernehmen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : selectedHazard ? (
|
|
<div className="text-center py-12 text-gray-500">
|
|
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-gray-500">
|
|
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface MitigationFormData {
|
|
title: string
|
|
description: string
|
|
reduction_type: 'design' | 'protection' | 'information'
|
|
linked_hazard_ids: string[]
|
|
}
|
|
|
|
function MitigationForm({
|
|
onSubmit,
|
|
onCancel,
|
|
hazards,
|
|
preselectedType,
|
|
onOpenLibrary,
|
|
}: {
|
|
onSubmit: (data: MitigationFormData) => void
|
|
onCancel: () => void
|
|
hazards: Hazard[]
|
|
preselectedType?: 'design' | 'protection' | 'information'
|
|
onOpenLibrary: (type?: string) => void
|
|
}) {
|
|
const [formData, setFormData] = useState<MitigationFormData>({
|
|
title: '',
|
|
description: '',
|
|
reduction_type: preselectedType || 'design',
|
|
linked_hazard_ids: [],
|
|
})
|
|
|
|
function toggleHazard(id: string) {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
|
|
? prev.linked_hazard_ids.filter((h) => h !== id)
|
|
: [...prev.linked_hazard_ids, id],
|
|
}))
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
|
|
<button
|
|
onClick={() => onOpenLibrary(formData.reduction_type)}
|
|
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
|
>
|
|
Aus Bibliothek waehlen
|
|
</button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title}
|
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reduktionstyp</label>
|
|
<select
|
|
value={formData.reduction_type}
|
|
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
>
|
|
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
|
|
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
|
|
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
|
<textarea
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
rows={2}
|
|
placeholder="Detaillierte Beschreibung der Massnahme..."
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
{hazards.length > 0 && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{hazards.map((h) => (
|
|
<button
|
|
key={h.id}
|
|
onClick={() => toggleHazard(h.id)}
|
|
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
|
formData.linked_hazard_ids.includes(h.id)
|
|
? 'border-purple-400 bg-purple-50 text-purple-700'
|
|
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
{h.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<button
|
|
onClick={() => onSubmit(formData)}
|
|
disabled={!formData.title}
|
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
formData.title
|
|
? 'bg-purple-600 text-white hover:bg-purple-700'
|
|
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
Hinzufuegen
|
|
</button>
|
|
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MitigationCard({
|
|
mitigation,
|
|
onVerify,
|
|
onDelete,
|
|
}: {
|
|
mitigation: Mitigation
|
|
onVerify: (id: string) => void
|
|
onDelete: (id: string) => void
|
|
}) {
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
|
{mitigation.title.startsWith('Auto:') && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
|
Auto
|
|
</span>
|
|
)}
|
|
</div>
|
|
<StatusBadge status={mitigation.status} />
|
|
</div>
|
|
{mitigation.description && (
|
|
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
|
|
)}
|
|
{mitigation.linked_hazard_names.length > 0 && (
|
|
<div className="mb-3">
|
|
<div className="flex flex-wrap gap-1">
|
|
{mitigation.linked_hazard_names.map((name, i) => (
|
|
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
|
{name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
{mitigation.status !== 'verified' && (
|
|
<button
|
|
onClick={() => onVerify(mitigation.id)}
|
|
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
|
>
|
|
Verifizieren
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => onDelete(mitigation.id)}
|
|
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
>
|
|
Loeschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function MitigationsPage() {
|
|
const params = useParams()
|
|
const projectId = params.projectId as string
|
|
const [mitigations, setMitigations] = useState<Mitigation[]>([])
|
|
const [hazards, setHazards] = useState<Hazard[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
|
|
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
|
|
const [showLibrary, setShowLibrary] = useState(false)
|
|
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
|
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
|
// Phase 5: Suggest measures
|
|
const [showSuggest, setShowSuggest] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
}, [projectId])
|
|
|
|
async function fetchData() {
|
|
try {
|
|
const [mitRes, hazRes] = await Promise.all([
|
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
|
])
|
|
if (mitRes.ok) {
|
|
const json = await mitRes.json()
|
|
const mits = json.mitigations || json || []
|
|
setMitigations(mits)
|
|
// Check hierarchy: if information-only measures exist without design/protection
|
|
validateHierarchy(mits)
|
|
}
|
|
if (hazRes.ok) {
|
|
const json = await hazRes.json()
|
|
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch data:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function validateHierarchy(mits: Mitigation[]) {
|
|
if (mits.length === 0) return
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
mitigations: mits.map((m) => ({
|
|
reduction_type: m.reduction_type,
|
|
linked_hazard_ids: m.linked_hazard_ids,
|
|
})),
|
|
}),
|
|
})
|
|
if (res.ok) {
|
|
const json = await res.json()
|
|
setHierarchyWarning(json.has_warning === true)
|
|
}
|
|
} catch {
|
|
// Non-critical, ignore
|
|
}
|
|
}
|
|
|
|
async function fetchMeasuresLibrary(type?: string) {
|
|
try {
|
|
const url = type
|
|
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
|
|
: '/api/sdk/v1/iace/protective-measures-library'
|
|
const res = await fetch(url)
|
|
if (res.ok) {
|
|
const json = await res.json()
|
|
setMeasures(json.protective_measures || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch measures library:', err)
|
|
}
|
|
}
|
|
|
|
function handleOpenLibrary(type?: string) {
|
|
setLibraryFilter(type)
|
|
fetchMeasuresLibrary(type)
|
|
setShowLibrary(true)
|
|
}
|
|
|
|
function handleSelectMeasure(measure: ProtectiveMeasure) {
|
|
setShowLibrary(false)
|
|
setShowForm(true)
|
|
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
|
}
|
|
|
|
async function handleSubmit(data: MitigationFormData) {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
})
|
|
if (res.ok) {
|
|
setShowForm(false)
|
|
setPreselectedType(undefined)
|
|
await fetchData()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to add mitigation:', err)
|
|
}
|
|
}
|
|
|
|
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title,
|
|
description,
|
|
reduction_type: reductionType,
|
|
linked_hazard_ids: [hazardId],
|
|
}),
|
|
})
|
|
if (res.ok) {
|
|
await fetchData()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to add suggested measure:', err)
|
|
}
|
|
}
|
|
|
|
async function handleVerify(id: string) {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
if (res.ok) {
|
|
await fetchData()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to verify mitigation:', err)
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
if (!confirm('Massnahme wirklich loeschen?')) return
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
|
|
if (res.ok) {
|
|
await fetchData()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete mitigation:', err)
|
|
}
|
|
}
|
|
|
|
function handleAddForType(type: 'design' | 'protection' | 'information') {
|
|
setPreselectedType(type)
|
|
setShowForm(true)
|
|
}
|
|
|
|
const byType = {
|
|
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
|
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
|
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Risikominderung nach dem 3-Stufen-Verfahren: Design → Schutz → Information.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{hazards.length > 0 && (
|
|
<button
|
|
onClick={() => setShowSuggest(true)}
|
|
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
|
</svg>
|
|
Vorschlaege
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleOpenLibrary()}
|
|
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
</svg>
|
|
Bibliothek
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setPreselectedType(undefined)
|
|
setShowForm(true)
|
|
}}
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
Massnahme hinzufuegen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hierarchy Warning */}
|
|
{hierarchyWarning && (
|
|
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
|
|
)}
|
|
|
|
{/* Form */}
|
|
{showForm && (
|
|
<MitigationForm
|
|
onSubmit={handleSubmit}
|
|
onCancel={() => {
|
|
setShowForm(false)
|
|
setPreselectedType(undefined)
|
|
}}
|
|
hazards={hazards}
|
|
preselectedType={preselectedType}
|
|
onOpenLibrary={handleOpenLibrary}
|
|
/>
|
|
)}
|
|
|
|
{/* Measures Library Modal */}
|
|
{showLibrary && (
|
|
<MeasuresLibraryModal
|
|
measures={measures}
|
|
onSelect={handleSelectMeasure}
|
|
onClose={() => setShowLibrary(false)}
|
|
filterType={libraryFilter}
|
|
/>
|
|
)}
|
|
|
|
{/* Suggest Measures Modal (Phase 5) */}
|
|
{showSuggest && (
|
|
<SuggestMeasuresModal
|
|
hazards={hazards}
|
|
projectId={projectId}
|
|
onAddMeasure={handleAddSuggestedMeasure}
|
|
onClose={() => setShowSuggest(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* 3-Column Layout */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{(['design', 'protection', 'information'] as const).map((type) => {
|
|
const config = REDUCTION_TYPES[type]
|
|
const items = byType[type]
|
|
return (
|
|
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
|
|
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
|
{config.icon}
|
|
<div>
|
|
<h3 className="text-sm font-semibold">{config.label}</h3>
|
|
<p className="text-xs opacity-75">{config.description}</p>
|
|
</div>
|
|
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
|
</div>
|
|
|
|
{/* Sub-types overview */}
|
|
<div className="mb-3 flex flex-wrap gap-1">
|
|
{config.subTypes.map((st) => (
|
|
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
|
|
{st.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{items.map((m) => (
|
|
<MitigationCard
|
|
key={m.id}
|
|
mitigation={m}
|
|
onVerify={handleVerify}
|
|
onDelete={handleDelete}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-3 flex gap-2">
|
|
<button
|
|
onClick={() => handleAddForType(type)}
|
|
className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
|
>
|
|
+ Hinzufuegen
|
|
</button>
|
|
<button
|
|
onClick={() => handleOpenLibrary(type)}
|
|
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
|
title="Aus Bibliothek waehlen"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|