merge: sync with origin/main, take upstream on conflicts
# Conflicts: # admin-compliance/lib/sdk/types.ts # admin-compliance/lib/sdk/vendor-compliance/types.ts
This commit is contained in:
@@ -12,6 +12,46 @@ interface Component {
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
library_component_id?: string
|
||||
energy_source_ids?: string[]
|
||||
}
|
||||
|
||||
interface LibraryComponent {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
category: string
|
||||
description_de: string
|
||||
typical_hazard_categories: string[]
|
||||
typical_energy_sources: string[]
|
||||
maps_to_component_type: string
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface EnergySource {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
description_de: string
|
||||
typical_components: string[]
|
||||
typical_hazard_categories: string[]
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
const LIBRARY_CATEGORIES: Record<string, string> = {
|
||||
mechanical: 'Mechanik',
|
||||
structural: 'Struktur',
|
||||
drive: 'Antrieb',
|
||||
hydraulic: 'Hydraulik',
|
||||
pneumatic: 'Pneumatik',
|
||||
electrical: 'Elektrik',
|
||||
control: 'Steuerung',
|
||||
sensor: 'Sensorik',
|
||||
actuator: 'Aktorik',
|
||||
safety: 'Sicherheit',
|
||||
it_network: 'IT/Netzwerk',
|
||||
}
|
||||
|
||||
const COMPONENT_TYPES = [
|
||||
@@ -98,6 +138,11 @@ function ComponentTreeNode({
|
||||
Sicherheitsrelevant
|
||||
</span>
|
||||
)}
|
||||
{component.library_component_id && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
Bibliothek
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
@@ -289,6 +334,289 @@ function buildTree(components: Component[]): Component[] {
|
||||
return roots
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Library Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function ComponentLibraryModal({
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
|
||||
const [energySources, setEnergySources] = useState<EnergySource[]>([])
|
||||
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
|
||||
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCategory, setFilterCategory] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [compRes, enRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/iace/component-library'),
|
||||
fetch('/api/sdk/v1/iace/energy-sources'),
|
||||
])
|
||||
if (compRes.ok) {
|
||||
const json = await compRes.json()
|
||||
setLibraryComponents(json.components || [])
|
||||
}
|
||||
if (enRes.ok) {
|
||||
const json = await enRes.json()
|
||||
setEnergySources(json.energy_sources || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch library:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
function toggleComponent(id: string) {
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleEnergySource(id: string) {
|
||||
setSelectedEnergySources(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAllInCategory(category: string) {
|
||||
const items = libraryComponents.filter(c => c.category === category)
|
||||
const allIds = items.map(i => i.id)
|
||||
const allSelected = allIds.every(id => selectedComponents.has(id))
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
|
||||
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
|
||||
onAdd(selComps, selEnergy)
|
||||
}
|
||||
|
||||
const filtered = libraryComponents.filter(c => {
|
||||
if (filterCategory && c.category !== filterCategory) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
|
||||
if (!acc[c.category]) acc[c.category] = []
|
||||
acc[c.category].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const categories = Object.keys(LIBRARY_CATEGORIES)
|
||||
const totalSelected = selectedComponents.size + selectedEnergySources.size
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
|
||||
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-4xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<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">Komponentenbibliothek</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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('components')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Komponenten ({libraryComponents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('energy')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Energiequellen ({energySources.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'components' && (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="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"
|
||||
/>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={e => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'components' ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped)
|
||||
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
|
||||
.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{LIBRARY_CATEGORIES[category] || category}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">({items.length})</span>
|
||||
<button
|
||||
onClick={() => toggleAllInCategory(category)}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 ml-auto"
|
||||
>
|
||||
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(comp => (
|
||||
<label
|
||||
key={comp.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedComponents.has(comp.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedComponents.has(comp.id)}
|
||||
onChange={() => toggleComponent(comp.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
|
||||
<ComponentTypeIcon type={comp.maps_to_component_type} />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
|
||||
{comp.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{energySources.map(es => (
|
||||
<label
|
||||
key={es.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedEnergySources.has(es.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEnergySources.has(es.id)}
|
||||
onChange={() => toggleEnergySource(es.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{es.id}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
|
||||
{es.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={totalSelected === 0}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
totalSelected > 0
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page
|
||||
// ============================================================================
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
@@ -297,6 +625,7 @@ export default function ComponentsPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
@@ -365,6 +694,32 @@ export default function ComponentsPage() {
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
||||
setShowLibrary(false)
|
||||
const energySourceIds = energySrcs.map(e => e.id)
|
||||
|
||||
for (const comp of libraryComps) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: comp.name_de,
|
||||
type: comp.maps_to_component_type,
|
||||
description: comp.description_de,
|
||||
safety_relevant: false,
|
||||
library_component_id: comp.id,
|
||||
energy_source_ids: energySourceIds,
|
||||
tags: comp.tags,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to add component ${comp.id}:`, err)
|
||||
}
|
||||
}
|
||||
await fetchComponents()
|
||||
}
|
||||
|
||||
const tree = buildTree(components)
|
||||
|
||||
if (loading) {
|
||||
@@ -386,22 +741,41 @@ export default function ComponentsPage() {
|
||||
</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
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>
|
||||
Komponente hinzufuegen
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowLibrary(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-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="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>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
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>
|
||||
Komponente hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Library Modal */}
|
||||
{showLibrary && (
|
||||
<ComponentLibraryModal
|
||||
onAdd={handleAddFromLibrary}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<ComponentForm
|
||||
@@ -454,12 +828,20 @@ export default function ComponentsPage() {
|
||||
Beginnen Sie mit der Erfassung aller relevanten Komponenten Ihrer Maschine.
|
||||
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Komponente hinzufuegen
|
||||
</button>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowLibrary(true)}
|
||||
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,20 +14,51 @@ interface Mitigation {
|
||||
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: '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" />
|
||||
@@ -35,10 +66,21 @@ const REDUCTION_TYPES = {
|
||||
),
|
||||
},
|
||||
protection: {
|
||||
label: 'Schutz',
|
||||
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" />
|
||||
@@ -46,10 +88,18 @@ const REDUCTION_TYPES = {
|
||||
),
|
||||
},
|
||||
information: {
|
||||
label: '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" />
|
||||
@@ -76,6 +126,281 @@ function StatusBadge({ status }: { status: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -88,11 +413,13 @@ function MitigationForm({
|
||||
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: '',
|
||||
@@ -112,7 +439,15 @@ function MitigationForm({
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neue Massnahme</h3>
|
||||
<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>
|
||||
@@ -132,9 +467,9 @@ function MitigationForm({
|
||||
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">Design - Inhaerent sichere Konstruktion</option>
|
||||
<option value="protection">Schutz - Technische Schutzmassnahmen</option>
|
||||
<option value="information">Information - Hinweise und Schulungen</option>
|
||||
<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>
|
||||
@@ -201,7 +536,14 @@ function MitigationCard({
|
||||
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">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
<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 && (
|
||||
@@ -246,6 +588,12 @@ export default function MitigationsPage() {
|
||||
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()
|
||||
@@ -259,11 +607,14 @@ export default function MitigationsPage() {
|
||||
])
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
setMitigations(json.mitigations || 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 })))
|
||||
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)
|
||||
@@ -272,6 +623,55 @@ export default function MitigationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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`, {
|
||||
@@ -289,6 +689,26 @@ export default function MitigationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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`, {
|
||||
@@ -341,23 +761,50 @@ export default function MitigationsPage() {
|
||||
<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.
|
||||
Risikominderung nach dem 3-Stufen-Verfahren: Design → Schutz → Information.
|
||||
</p>
|
||||
</div>
|
||||
<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 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
|
||||
@@ -368,6 +815,27 @@ export default function MitigationsPage() {
|
||||
}}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -378,7 +846,7 @@ export default function MitigationsPage() {
|
||||
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-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>
|
||||
@@ -387,6 +855,15 @@ export default function MitigationsPage() {
|
||||
<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
|
||||
@@ -398,12 +875,23 @@ export default function MitigationsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleAddForType(type)}
|
||||
className="mt-3 w-full 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"
|
||||
>
|
||||
+ Massnahme hinzufuegen
|
||||
</button>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
|
||||
|
||||
interface TechFileSection {
|
||||
id: string
|
||||
@@ -67,6 +68,14 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: str
|
||||
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||
}
|
||||
|
||||
const EXPORT_FORMATS: { value: string; label: string; extension: string }[] = [
|
||||
{ value: 'pdf', label: 'PDF', extension: '.pdf' },
|
||||
{ value: 'xlsx', label: 'Excel', extension: '.xlsx' },
|
||||
{ value: 'docx', label: 'Word', extension: '.docx' },
|
||||
{ value: 'md', label: 'Markdown', extension: '.md' },
|
||||
{ value: 'json', label: 'JSON', extension: '.json' },
|
||||
]
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.empty
|
||||
return (
|
||||
@@ -87,7 +96,6 @@ function SectionViewer({
|
||||
onApprove: (id: string) => void
|
||||
onSave: (id: string, content: string) => void
|
||||
}) {
|
||||
const [editedContent, setEditedContent] = useState(section.content || '')
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -111,13 +119,10 @@ function SectionViewer({
|
||||
)}
|
||||
{editing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onSave(section.id, editedContent)
|
||||
setEditing(false)
|
||||
}}
|
||||
className="text-sm px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-sm px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Speichern
|
||||
Fertig
|
||||
</button>
|
||||
)}
|
||||
{section.status !== 'approved' && section.content && !editing && (
|
||||
@@ -136,19 +141,19 @@ function SectionViewer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
rows={20}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
) : section.content ? (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-750 p-4 rounded-lg">
|
||||
{section.content}
|
||||
</pre>
|
||||
</div>
|
||||
{section.content ? (
|
||||
editing ? (
|
||||
<TechFileEditor
|
||||
content={section.content}
|
||||
onSave={(html) => onSave(section.id, html)}
|
||||
/>
|
||||
) : (
|
||||
<TechFileEditor
|
||||
content={section.content}
|
||||
onSave={() => {}}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Kein Inhalt vorhanden. Klicken Sie "Generieren" um den Abschnitt zu erstellen.
|
||||
@@ -167,6 +172,21 @@ export default function TechFilePage() {
|
||||
const [generatingSection, setGeneratingSection] = useState<string | null>(null)
|
||||
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [showExportMenu, setShowExportMenu] = useState(false)
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close export menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (exportMenuRef.current && !exportMenuRef.current.contains(event.target as Node)) {
|
||||
setShowExportMenu(false)
|
||||
}
|
||||
}
|
||||
if (showExportMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showExportMenu])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSections()
|
||||
@@ -236,18 +256,22 @@ export default function TechFilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportZip() {
|
||||
async function handleExport(format: string) {
|
||||
setExporting(true)
|
||||
setShowExportMenu(false)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/export`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const res = await fetch(
|
||||
`/api/sdk/v1/iace/projects/${projectId}/tech-file/export?format=${format}`,
|
||||
{ method: 'GET' }
|
||||
)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const formatConfig = EXPORT_FORMATS.find((f) => f.value === format)
|
||||
const extension = formatConfig?.extension || `.${format}`
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `CE-Akte-${projectId}.zip`
|
||||
a.download = `CE-Akte-${projectId}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
@@ -284,25 +308,45 @@ export default function TechFilePage() {
|
||||
Sie alle erforderlichen Abschnitte.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportZip}
|
||||
disabled={!allRequiredApproved || exporting}
|
||||
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte als ZIP exportieren'}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
allRequiredApproved && !exporting
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{exporting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
{/* Export Dropdown */}
|
||||
<div className="relative" ref={exportMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowExportMenu((prev) => !prev)}
|
||||
disabled={!allRequiredApproved || exporting}
|
||||
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte exportieren'}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
allRequiredApproved && !exporting
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{exporting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
)}
|
||||
Exportieren
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showExportMenu && allRequiredApproved && !exporting && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
|
||||
{EXPORT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.value}
|
||||
onClick={() => handleExport(fmt.value)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className="text-xs font-mono uppercase w-10 text-gray-400">{fmt.extension}</span>
|
||||
<span>{fmt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
ZIP exportieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
|
||||
@@ -19,14 +19,25 @@ interface VerificationItem {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface SuggestedEvidence {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
method: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const VERIFICATION_METHODS = [
|
||||
{ value: 'test', label: 'Test' },
|
||||
{ value: 'analysis', label: 'Analyse' },
|
||||
{ value: 'inspection', label: 'Inspektion' },
|
||||
{ value: 'simulation', label: 'Simulation' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'demonstration', label: 'Demonstration' },
|
||||
{ value: 'certification', label: 'Zertifizierung' },
|
||||
{ value: 'design_review', label: 'Design-Review', description: 'Systematische Pruefung der Konstruktionsunterlagen' },
|
||||
{ value: 'calculation', label: 'Berechnung', description: 'Rechnerischer Nachweis (FEM, Festigkeit, Thermik)' },
|
||||
{ value: 'test_report', label: 'Pruefbericht', description: 'Dokumentierter Test mit Messprotokoll' },
|
||||
{ value: 'validation', label: 'Validierung', description: 'Nachweis der Eignung unter realen Betriebsbedingungen' },
|
||||
{ value: 'electrical_test', label: 'Elektrische Pruefung', description: 'Isolationsmessung, Schutzleiter, Spannungsfestigkeit' },
|
||||
{ value: 'software_test', label: 'Software-Test', description: 'Unit-, Integrations- oder Systemtest der Steuerungssoftware' },
|
||||
{ value: 'penetration_test', label: 'Penetrationstest', description: 'Security-Test der Netzwerk- und Steuerungskomponenten' },
|
||||
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll', description: 'Formelle Abnahme mit Checkliste und Unterschrift' },
|
||||
{ value: 'user_test', label: 'Anwendertest', description: 'Pruefung durch Bediener unter realen Einsatzbedingungen' },
|
||||
{ value: 'documentation_release', label: 'Dokumentenfreigabe', description: 'Formelle Freigabe der technischen Dokumentation' },
|
||||
]
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
@@ -238,6 +249,130 @@ function CompleteModal({
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Suggest Evidence Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function SuggestEvidenceModal({
|
||||
mitigations,
|
||||
projectId,
|
||||
onAddEvidence,
|
||||
onClose,
|
||||
}: {
|
||||
mitigations: { id: string; title: string }[]
|
||||
projectId: string
|
||||
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
|
||||
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
|
||||
async function handleSelectMitigation(mitigationId: string) {
|
||||
setSelectedMitigation(mitigationId)
|
||||
setSuggested([])
|
||||
if (!mitigationId) return
|
||||
|
||||
setLoadingSuggestions(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSuggested(json.suggested_evidence || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to suggest evidence:', err)
|
||||
} finally {
|
||||
setLoadingSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
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">Nachweise vorschlagen</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 Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mitigations.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleSelectMitigation(m.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
selectedMitigation === m.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
{m.title}
|
||||
</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-3">
|
||||
{suggested.map(ev => (
|
||||
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 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">{ev.id}</span>
|
||||
{ev.method && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
|
||||
{VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
|
||||
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>
|
||||
) : selectedMitigation ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Vorschlaege fuer diese Massnahme gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page
|
||||
// ============================================================================
|
||||
|
||||
export default function VerificationPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
@@ -247,6 +382,8 @@ export default function VerificationPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||
// Phase 5: Suggest evidence
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -294,6 +431,26 @@ export default function VerificationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
method,
|
||||
linked_mitigation_id: mitigationId,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add suggested evidence:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||
@@ -344,15 +501,28 @@ export default function VerificationPage() {
|
||||
Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => 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>
|
||||
Verifikation hinzufuegen
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{mitigations.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>
|
||||
Nachweise vorschlagen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => 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>
|
||||
Verifikation hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -396,6 +566,16 @@ export default function VerificationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Suggest Evidence Modal (Phase 5) */}
|
||||
{showSuggest && (
|
||||
<SuggestEvidenceModal
|
||||
mitigations={mitigations}
|
||||
projectId={projectId}
|
||||
onAddEvidence={handleAddSuggestedEvidence}
|
||||
onClose={() => setShowSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{items.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
@@ -469,12 +649,22 @@ export default function VerificationPage() {
|
||||
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
|
||||
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Verifikation anlegen
|
||||
</button>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{mitigations.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowSuggest(true)}
|
||||
className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors"
|
||||
>
|
||||
Nachweise vorschlagen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Verifikation anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user