refactor: Admin-Layout komplett entfernt — SDK als einziges Layout
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface Classification {
|
||||
id: string
|
||||
regulation: string
|
||||
regulation_label: string
|
||||
classification_result: string
|
||||
risk_level: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
classified_at: string | null
|
||||
}
|
||||
|
||||
const REGULATIONS = [
|
||||
{
|
||||
key: 'ai_act',
|
||||
label: 'AI Act',
|
||||
description: 'EU-Verordnung ueber kuenstliche Intelligenz (2024/1689)',
|
||||
icon: '🤖',
|
||||
},
|
||||
{
|
||||
key: 'machinery_regulation',
|
||||
label: 'Maschinenverordnung',
|
||||
description: 'EU-Maschinenverordnung (2023/1230)',
|
||||
icon: '⚙️',
|
||||
},
|
||||
{
|
||||
key: 'cra',
|
||||
label: 'Cyber Resilience Act',
|
||||
description: 'EU-Verordnung ueber Cyberresilienz',
|
||||
icon: '🔒',
|
||||
},
|
||||
{
|
||||
key: 'nis2',
|
||||
label: 'NIS2',
|
||||
description: 'Richtlinie ueber Netz- und Informationssicherheit',
|
||||
icon: '🌐',
|
||||
},
|
||||
]
|
||||
|
||||
function RiskLevelBadge({ level }: { level: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
unacceptable: 'bg-black text-white',
|
||||
high: 'bg-red-100 text-red-700',
|
||||
limited: 'bg-yellow-100 text-yellow-700',
|
||||
minimal: 'bg-green-100 text-green-700',
|
||||
not_applicable: 'bg-gray-100 text-gray-500',
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
important: 'bg-orange-100 text-orange-700',
|
||||
default_category: 'bg-blue-100 text-blue-700',
|
||||
essential: 'bg-orange-100 text-orange-700',
|
||||
non_essential: 'bg-green-100 text-green-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
unacceptable: 'Unakzeptabel',
|
||||
high: 'Hochrisiko',
|
||||
limited: 'Begrenztes Risiko',
|
||||
minimal: 'Minimales Risiko',
|
||||
not_applicable: 'Nicht anwendbar',
|
||||
critical: 'Kritisch',
|
||||
important: 'Wichtig',
|
||||
default_category: 'Standard',
|
||||
essential: 'Wesentlich',
|
||||
non_essential: 'Nicht wesentlich',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[level] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{labels[level] || level}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfidenceBar({ value }: { value: number }) {
|
||||
const pct = Math.round(value * 100)
|
||||
const color = pct >= 80 ? 'bg-green-500' : pct >= 60 ? 'bg-yellow-500' : 'bg-orange-500'
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-1.5">
|
||||
<div className={`${color} h-1.5 rounded-full`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClassificationCard({
|
||||
regulation,
|
||||
classification,
|
||||
onReclassify,
|
||||
classifying,
|
||||
}: {
|
||||
regulation: (typeof REGULATIONS)[number]
|
||||
classification: Classification | null
|
||||
onReclassify: (regKey: string) => void
|
||||
classifying: boolean
|
||||
}) {
|
||||
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-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{regulation.icon}</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{regulation.label}</h3>
|
||||
<p className="text-xs text-gray-500">{regulation.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onReclassify(regulation.key)}
|
||||
disabled={classifying}
|
||||
className="text-xs px-3 py-1.5 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{classifying ? 'Laeuft...' : classification ? 'Neu klassifizieren' : 'Klassifizieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{classification ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Ergebnis</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{classification.classification_result}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Risikostufe</span>
|
||||
<RiskLevelBadge level={classification.risk_level} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Konfidenz</span>
|
||||
<ConfidenceBar value={classification.confidence} />
|
||||
</div>
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500 block mb-1">Begruendung</span>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{classification.reasoning}</p>
|
||||
</div>
|
||||
{classification.classified_at && (
|
||||
<div className="text-xs text-gray-400">
|
||||
Klassifiziert am: {new Date(classification.classified_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">Noch nicht klassifiziert</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Klicken Sie "Klassifizieren" um die Analyse zu starten</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ClassificationPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [classifications, setClassifications] = useState<Classification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [classifyingAll, setClassifyingAll] = useState(false)
|
||||
const [classifyingReg, setClassifyingReg] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchClassifications()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchClassifications() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/classifications`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setClassifications(json.classifications || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch classifications:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClassifyAll() {
|
||||
setClassifyingAll(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/classifications/classify-all`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchClassifications()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to classify all:', err)
|
||||
} finally {
|
||||
setClassifyingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReclassify(regKey: string) {
|
||||
setClassifyingReg(regKey)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/classifications/${regKey}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchClassifications()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to reclassify:', err)
|
||||
} finally {
|
||||
setClassifyingReg(null)
|
||||
}
|
||||
}
|
||||
|
||||
function getClassificationForReg(regKey: string): Classification | null {
|
||||
return classifications.find((c) => c.regulation === regKey) || null
|
||||
}
|
||||
|
||||
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">Regulatorische Klassifikation</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatische Einordnung Ihrer Maschine/Anlage in die relevanten EU-Regulierungsrahmen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClassifyAll}
|
||||
disabled={classifyingAll}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{classifyingAll ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
Wird klassifiziert...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Alle klassifizieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Classification Cards */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{REGULATIONS.map((reg) => (
|
||||
<ClassificationCard
|
||||
key={reg.key}
|
||||
regulation={reg}
|
||||
classification={getClassificationForReg(reg.key)}
|
||||
onReclassify={handleReclassify}
|
||||
classifying={classifyingReg === reg.key || classifyingAll}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
468
admin-compliance/app/sdk/iace/[projectId]/components/page.tsx
Normal file
468
admin-compliance/app/sdk/iace/[projectId]/components/page.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface Component {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
}
|
||||
|
||||
const COMPONENT_TYPES = [
|
||||
{ value: 'SW', label: 'Software (SW)' },
|
||||
{ value: 'FW', label: 'Firmware (FW)' },
|
||||
{ value: 'AI', label: 'KI-Modul (AI)' },
|
||||
{ value: 'HMI', label: 'Mensch-Maschine-Schnittstelle (HMI)' },
|
||||
{ value: 'SENSOR', label: 'Sensor' },
|
||||
{ value: 'ACTUATOR', label: 'Aktor' },
|
||||
{ value: 'CONTROLLER', label: 'Steuerung' },
|
||||
{ value: 'NETWORK', label: 'Netzwerk' },
|
||||
{ value: 'MECHANICAL', label: 'Mechanik' },
|
||||
{ value: 'ELECTRICAL', label: 'Elektrik' },
|
||||
{ value: 'OTHER', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
function ComponentTypeIcon({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
SW: 'bg-blue-100 text-blue-700',
|
||||
FW: 'bg-indigo-100 text-indigo-700',
|
||||
AI: 'bg-purple-100 text-purple-700',
|
||||
HMI: 'bg-pink-100 text-pink-700',
|
||||
SENSOR: 'bg-cyan-100 text-cyan-700',
|
||||
ACTUATOR: 'bg-orange-100 text-orange-700',
|
||||
CONTROLLER: 'bg-green-100 text-green-700',
|
||||
NETWORK: 'bg-yellow-100 text-yellow-700',
|
||||
MECHANICAL: 'bg-gray-100 text-gray-700',
|
||||
ELECTRICAL: 'bg-red-100 text-red-700',
|
||||
OTHER: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[type] || colors.OTHER}`}>
|
||||
{type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentTreeNode({
|
||||
component,
|
||||
depth,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: {
|
||||
component: Component
|
||||
depth: number
|
||||
onEdit: (c: Component) => void
|
||||
onDelete: (id: string) => void
|
||||
onAddChild: (parentId: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const hasChildren = component.children && component.children.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 group transition-colors"
|
||||
style={{ paddingLeft: `${depth * 24 + 12}px` }}
|
||||
>
|
||||
{/* Expand/collapse */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ComponentTypeIcon type={component.type} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{component.name}</span>
|
||||
{component.version && (
|
||||
<span className="ml-2 text-xs text-gray-400">v{component.version}</span>
|
||||
)}
|
||||
{component.safety_relevant && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
|
||||
Sicherheitsrelevant
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">
|
||||
{component.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onAddChild(component.id)}
|
||||
title="Unterkomponente hinzufuegen"
|
||||
className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded 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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(component)}
|
||||
title="Bearbeiten"
|
||||
className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(component.id)}
|
||||
title="Loeschen"
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasChildren && (
|
||||
<div>
|
||||
{component.children.map((child) => (
|
||||
<ComponentTreeNode
|
||||
key={child.id}
|
||||
component={child}
|
||||
depth={depth + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ComponentFormData {
|
||||
name: string
|
||||
type: string
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
}
|
||||
|
||||
function ComponentForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
parentId,
|
||||
}: {
|
||||
onSubmit: (data: ComponentFormData) => void
|
||||
onCancel: () => void
|
||||
initialData?: Component | null
|
||||
parentId?: string | null
|
||||
}) {
|
||||
const [formData, setFormData] = useState<ComponentFormData>({
|
||||
name: initialData?.name || '',
|
||||
type: initialData?.type || 'SW',
|
||||
version: initialData?.version || '',
|
||||
description: initialData?.description || '',
|
||||
safety_relevant: initialData?.safety_relevant || false,
|
||||
parent_id: parentId || initialData?.parent_id || null,
|
||||
})
|
||||
|
||||
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">
|
||||
{initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
|
||||
</h3>
|
||||
<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">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Bildverarbeitungsmodul"
|
||||
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">Typ</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
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"
|
||||
>
|
||||
{COMPONENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
placeholder="z.B. 1.2.0"
|
||||
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 className="flex items-center gap-3 pt-6">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.safety_relevant}
|
||||
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-500" />
|
||||
</label>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<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 })}
|
||||
placeholder="Kurze Beschreibung der Komponente..."
|
||||
rows={2}
|
||||
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>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{initialData ? 'Aktualisieren' : '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 buildTree(components: Component[]): Component[] {
|
||||
const map = new Map<string, Component>()
|
||||
const roots: Component[] = []
|
||||
|
||||
components.forEach((c) => {
|
||||
map.set(c.id, { ...c, children: [] })
|
||||
})
|
||||
|
||||
components.forEach((c) => {
|
||||
const node = map.get(c.id)!
|
||||
if (c.parent_id && map.has(c.parent_id)) {
|
||||
map.get(c.parent_id)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [components, setComponents] = useState<Component[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchComponents() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setComponents(json.components || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch components:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: ComponentFormData) {
|
||||
try {
|
||||
const url = editingComponent
|
||||
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
|
||||
: `/api/sdk/v1/iace/projects/${projectId}/components`
|
||||
const method = editingComponent ? 'PUT' : 'POST'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
await fetchComponents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save component:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchComponents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete component:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(component: Component) {
|
||||
setEditingComponent(component)
|
||||
setAddingParentId(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function handleAddChild(parentId: string) {
|
||||
setAddingParentId(parentId)
|
||||
setEditingComponent(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const tree = buildTree(components)
|
||||
|
||||
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">Komponenten</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Erfassen Sie alle Software-, Firmware-, KI- und Hardware-Komponenten der Maschine.
|
||||
</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>
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<ComponentForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
initialData={editingComponent}
|
||||
parentId={addingParentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Component Tree */}
|
||||
{tree.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-750 rounded-t-xl">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<span className="w-5" />
|
||||
<span>Typ</span>
|
||||
<span className="flex-1">Name</span>
|
||||
<span className="hidden lg:block w-[200px]">Beschreibung</span>
|
||||
<span className="w-24">Aktionen</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{tree.map((component) => (
|
||||
<ComponentTreeNode
|
||||
key={component.id}
|
||||
component={component}
|
||||
depth={0}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddChild={handleAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Keine Komponenten erfasst</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
393
admin-compliance/app/sdk/iace/[projectId]/evidence/page.tsx
Normal file
393
admin-compliance/app/sdk/iace/[projectId]/evidence/page.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface EvidenceFile {
|
||||
id: string
|
||||
filename: string
|
||||
file_type: string
|
||||
file_size: number
|
||||
description: string
|
||||
uploaded_at: string
|
||||
uploaded_by: string
|
||||
linked_mitigation_ids: string[]
|
||||
linked_mitigation_names: string[]
|
||||
linked_verification_ids: string[]
|
||||
linked_verification_names: string[]
|
||||
}
|
||||
|
||||
interface Linkable {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function FileIcon({ type }: { type: string }) {
|
||||
const isPdf = type.includes('pdf')
|
||||
const isImage = type.includes('image')
|
||||
const isDoc = type.includes('word') || type.includes('document')
|
||||
const isSpreadsheet = type.includes('sheet') || type.includes('excel')
|
||||
|
||||
const color = isPdf ? 'text-red-500' : isImage ? 'text-blue-500' : isDoc ? 'text-blue-600' : isSpreadsheet ? 'text-green-600' : 'text-gray-500'
|
||||
|
||||
return (
|
||||
<svg className={`w-8 h-8 ${color}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkBadges({ names, type }: { names: string[]; type: 'mitigation' | 'verification' }) {
|
||||
if (names.length === 0) return null
|
||||
const color = type === 'mitigation' ? 'bg-blue-50 text-blue-700' : 'bg-green-50 text-green-700'
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{names.map((name, i) => (
|
||||
<span key={i} className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs ${color}`}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkModal({
|
||||
evidence,
|
||||
mitigations,
|
||||
verifications,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
evidence: EvidenceFile
|
||||
mitigations: Linkable[]
|
||||
verifications: Linkable[]
|
||||
onSave: (evidenceId: string, mitIds: string[], verIds: string[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedMitigations, setSelectedMitigations] = useState<string[]>(evidence.linked_mitigation_ids)
|
||||
const [selectedVerifications, setSelectedVerifications] = useState<string[]>(evidence.linked_verification_ids)
|
||||
|
||||
function toggle(list: string[], setList: (v: string[]) => void, id: string) {
|
||||
setList(list.includes(id) ? list.filter((x) => x !== id) : [...list, id])
|
||||
}
|
||||
|
||||
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-lg 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">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Nachweis verknuepfen: {evidence.filename}
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
{mitigations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Massnahmen</h4>
|
||||
<div className="space-y-1">
|
||||
{mitigations.map((m) => (
|
||||
<label key={m.id} className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMitigations.includes(m.id)}
|
||||
onChange={() => toggle(selectedMitigations, setSelectedMitigations, m.id)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{m.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{verifications.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verifikationen</h4>
|
||||
<div className="space-y-1">
|
||||
{verifications.map((v) => (
|
||||
<label key={v.id} className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedVerifications.includes(v.id)}
|
||||
onChange={() => toggle(selectedVerifications, setSelectedVerifications, v.id)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{v.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSave(evidence.id, selectedMitigations, selectedVerifications)}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EvidencePage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [files, setFiles] = useState<EvidenceFile[]>([])
|
||||
const [mitigations, setMitigations] = useState<Linkable[]>([])
|
||||
const [verifications, setVerifications] = useState<Linkable[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [linkingFile, setLinkingFile] = useState<EvidenceFile | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [evRes, mitRes, verRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/evidence`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`),
|
||||
])
|
||||
if (evRes.ok) {
|
||||
const json = await evRes.json()
|
||||
setFiles(json.evidence || json || [])
|
||||
}
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, name: m.title })))
|
||||
}
|
||||
if (verRes.ok) {
|
||||
const json = await verRes.json()
|
||||
setVerifications((json.verifications || json || []).map((v: { id: string; title: string }) => ({ id: v.id, name: v.title })))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(fileList: FileList) {
|
||||
setUploading(true)
|
||||
try {
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i]
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('description', '')
|
||||
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/evidence`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to upload:', err)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
async function handleLink(evidenceId: string, mitIds: string[], verIds: string[]) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/evidence/${evidenceId}/link`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
linked_mitigation_ids: mitIds,
|
||||
linked_verification_ids: verIds,
|
||||
}),
|
||||
})
|
||||
setLinkingFile(null)
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to link evidence:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Nachweis wirklich loeschen?')) return
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/evidence/${id}`, { method: 'DELETE' })
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete evidence:', err)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Nachweise</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Laden Sie Nachweisdokumente hoch und verknuepfen Sie diese mit Massnahmen und Verifikationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
dragging
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-300 hover:border-purple-300 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||
<span className="text-sm text-gray-600">Wird hochgeladen...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-10 h-10 mx-auto text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium text-purple-600">Dateien auswaehlen</span> oder hierher ziehen
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PDF, Word, Excel, Bilder und andere Dokumente</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link Modal */}
|
||||
{linkingFile && (
|
||||
<LinkModal
|
||||
evidence={linkingFile}
|
||||
mitigations={mitigations}
|
||||
verifications={verifications}
|
||||
onSave={handleLink}
|
||||
onClose={() => setLinkingFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Hochgeladene Nachweise ({files.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{files.map((file) => (
|
||||
<div key={file.id} className="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<FileIcon type={file.file_type} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{file.filename}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatFileSize(file.file_size)}</span>
|
||||
</div>
|
||||
{file.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{file.description}</p>
|
||||
)}
|
||||
<div className="mt-2 space-y-1">
|
||||
<LinkBadges names={file.linked_mitigation_names} type="mitigation" />
|
||||
<LinkBadges names={file.linked_verification_names} type="verification" />
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Hochgeladen am {new Date(file.uploaded_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setLinkingFile(file)}
|
||||
className="text-xs px-2.5 py-1 border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Verknuepfen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(file.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Keine Nachweise vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Laden Sie Testberichte, Zertifikate, Analyseergebnisse und andere Nachweisdokumente
|
||||
hoch und verknuepfen Sie diese mit den entsprechenden Massnahmen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
628
admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx
Normal file
628
admin-compliance/app/sdk/iace/[projectId]/hazards/page.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface Hazard {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
component_id: string | null
|
||||
component_name: string | null
|
||||
category: string
|
||||
status: string
|
||||
severity: number
|
||||
exposure: number
|
||||
probability: number
|
||||
r_inherent: number
|
||||
risk_level: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface LibraryHazard {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
default_severity: number
|
||||
default_exposure: number
|
||||
default_probability: number
|
||||
}
|
||||
|
||||
const HAZARD_CATEGORIES = [
|
||||
'mechanical', 'electrical', 'thermal', 'noise', 'vibration',
|
||||
'radiation', 'material', 'ergonomic', 'software', 'ai_specific',
|
||||
'cybersecurity', 'functional_safety', 'environmental',
|
||||
]
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
mechanical: 'Mechanisch',
|
||||
electrical: 'Elektrisch',
|
||||
thermal: 'Thermisch',
|
||||
noise: 'Laerm',
|
||||
vibration: 'Vibration',
|
||||
radiation: 'Strahlung',
|
||||
material: 'Stoffe/Materialien',
|
||||
ergonomic: 'Ergonomie',
|
||||
software: 'Software',
|
||||
ai_specific: 'KI-spezifisch',
|
||||
cybersecurity: 'Cybersecurity',
|
||||
functional_safety: 'Funktionale Sicherheit',
|
||||
environmental: 'Umgebung',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
identified: 'Identifiziert',
|
||||
assessed: 'Bewertet',
|
||||
mitigated: 'Gemindert',
|
||||
accepted: 'Akzeptiert',
|
||||
closed: 'Geschlossen',
|
||||
}
|
||||
|
||||
function getRiskColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'critical': return 'bg-red-100 text-red-700 border-red-200'
|
||||
case 'high': return 'bg-orange-100 text-orange-700 border-orange-200'
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
case 'low': return 'bg-green-100 text-green-700 border-green-200'
|
||||
default: return 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
function getRiskLevel(r: number): string {
|
||||
if (r >= 100) return 'critical'
|
||||
if (r >= 50) return 'high'
|
||||
if (r >= 20) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function getRiskLevelLabel(level: string): string {
|
||||
switch (level) {
|
||||
case 'critical': return 'Kritisch'
|
||||
case 'high': return 'Hoch'
|
||||
case 'medium': return 'Mittel'
|
||||
case 'low': return 'Niedrig'
|
||||
default: return level
|
||||
}
|
||||
}
|
||||
|
||||
function RiskBadge({ level }: { level: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${getRiskColor(level)}`}>
|
||||
{getRiskLevelLabel(level)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface HazardFormData {
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
component_id: string
|
||||
severity: number
|
||||
exposure: number
|
||||
probability: number
|
||||
}
|
||||
|
||||
function HazardForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (data: HazardFormData) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<HazardFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'mechanical',
|
||||
component_id: '',
|
||||
severity: 3,
|
||||
exposure: 3,
|
||||
probability: 3,
|
||||
})
|
||||
|
||||
const rInherent = formData.severity * formData.exposure * formData.probability
|
||||
const riskLevel = getRiskLevel(rInherent)
|
||||
|
||||
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 Gefaehrdung</h3>
|
||||
<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">Bezeichnung *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Quetschung durch Roboterarm"
|
||||
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">Kategorie</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
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"
|
||||
>
|
||||
{HAZARD_CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</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 Gefaehrdung..."
|
||||
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>
|
||||
|
||||
{/* S/E/P Sliders */}
|
||||
<div className="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Risikobewertung (S x E x P)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Schwere (S): <span className="font-bold">{formData.severity}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={formData.severity}
|
||||
onChange={(e) => setFormData({ ...formData, severity: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Gering</span>
|
||||
<span>Toedlich</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Exposition (E): <span className="font-bold">{formData.exposure}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={formData.exposure}
|
||||
onChange={(e) => setFormData({ ...formData, exposure: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Selten</span>
|
||||
<span>Staendig</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Wahrscheinlichkeit (P): <span className="font-bold">{formData.probability}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={formData.probability}
|
||||
onChange={(e) => setFormData({ ...formData, probability: Number(e.target.value) })}
|
||||
className="w-full accent-purple-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Unwahrscheinlich</span>
|
||||
<span>Sehr wahrscheinlich</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-4 p-3 rounded-lg border ${getRiskColor(riskLevel)}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">R_inherent = S x E x P</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold">{rInherent}</span>
|
||||
<RiskBadge level={riskLevel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name
|
||||
? '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 LibraryModal({
|
||||
library,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
library: LibraryHazard[]
|
||||
onAdd: (item: LibraryHazard) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCat, setFilterCat] = useState('')
|
||||
|
||||
const filtered = library.filter((h) => {
|
||||
const matchSearch = !search || h.name.toLowerCase().includes(search.toLowerCase()) || h.description.toLowerCase().includes(search.toLowerCase())
|
||||
const matchCat = !filterCat || h.category === filterCat
|
||||
return matchSearch && matchCat
|
||||
})
|
||||
|
||||
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-2xl 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">Gefaehrdungsbibliothek</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>
|
||||
<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={filterCat}
|
||||
onChange={(e) => setFilterCat(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>
|
||||
{HAZARD_CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 space-y-2">
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750"
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{item.description}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">{CATEGORY_LABELS[item.category] || item.category}</span>
|
||||
<span className="text-xs text-gray-400">S:{item.default_severity} E:{item.default_exposure} P:{item.default_probability}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAdd(item)}
|
||||
className="flex-shrink-0 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">Keine Eintraege gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HazardsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [hazards, setHazards] = useState<Hazard[]>([])
|
||||
const [library, setLibrary] = useState<LibraryHazard[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
const [suggestingAI, setSuggestingAI] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchHazards()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchHazards() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setHazards(json.hazards || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hazards:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLibrary() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/hazard-library')
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setLibrary(json.hazards || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hazard library:', err)
|
||||
}
|
||||
setShowLibrary(true)
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(item: LibraryHazard) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
category: item.category,
|
||||
severity: item.default_severity,
|
||||
exposure: item.default_exposure,
|
||||
probability: item.default_probability,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchHazards()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add from library:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: HazardFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
await fetchHazards()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add hazard:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAISuggestions() {
|
||||
setSuggestingAI(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchHazards()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get AI suggestions:', err)
|
||||
} finally {
|
||||
setSuggestingAI(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Gefaehrdung wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
await fetchHazards()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete hazard:', err)
|
||||
}
|
||||
}
|
||||
|
||||
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-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Hazard Log</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Gefaehrdungsanalyse mit Risikobewertung nach S x E x P Methode.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleAISuggestions}
|
||||
disabled={suggestingAI}
|
||||
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 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{suggestingAI ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-600" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
KI-Vorschlaege
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchLibrary}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-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
|
||||
</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 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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{hazards.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{hazards.length}</div>
|
||||
<div className="text-xs text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{hazards.filter((h) => h.risk_level === 'critical').length}</div>
|
||||
<div className="text-xs text-red-600">Kritisch</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-orange-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">{hazards.filter((h) => h.risk_level === 'high').length}</div>
|
||||
<div className="text-xs text-orange-600">Hoch</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{hazards.filter((h) => h.risk_level === 'medium').length}</div>
|
||||
<div className="text-xs text-yellow-600">Mittel</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{hazards.filter((h) => h.risk_level === 'low').length}</div>
|
||||
<div className="text-xs text-green-600">Niedrig</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<HazardForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} />
|
||||
)}
|
||||
|
||||
{/* Library Modal */}
|
||||
{showLibrary && (
|
||||
<LibraryModal
|
||||
library={library}
|
||||
onAdd={handleAddFromLibrary}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hazard Table */}
|
||||
{hazards.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bezeichnung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">S</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">E</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">P</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">R</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Risiko</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{hazards
|
||||
.sort((a, b) => b.r_inherent - a.r_inherent)
|
||||
.map((hazard) => (
|
||||
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
|
||||
{hazard.description && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{hazard.component_name || '--'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.exposure}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.probability}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-bold">{hazard.r_inherent}</td>
|
||||
<td className="px-4 py-3"><RiskBadge level={hazard.risk_level} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-gray-500">{STATUS_LABELS[hazard.status] || hazard.status}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(hazard.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-orange-600" 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>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Hazard Log vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Beginnen Sie mit der systematischen Erfassung von Gefaehrdungen. Nutzen Sie die Bibliothek
|
||||
oder KI-Vorschlaege als Ausgangspunkt.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<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>
|
||||
<button
|
||||
onClick={fetchLibrary}
|
||||
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Bibliothek oeffnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
413
admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx
Normal file
413
admin-compliance/app/sdk/iace/[projectId]/mitigations/page.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
'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
|
||||
}
|
||||
|
||||
interface Hazard {
|
||||
id: string
|
||||
name: string
|
||||
risk_level: string
|
||||
}
|
||||
|
||||
const REDUCTION_TYPES = {
|
||||
design: {
|
||||
label: 'Design',
|
||||
description: 'Inhaerent sichere Konstruktion',
|
||||
color: 'border-blue-200 bg-blue-50',
|
||||
headerColor: 'bg-blue-100 text-blue-800',
|
||||
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: 'Schutz',
|
||||
description: 'Technische Schutzmassnahmen',
|
||||
color: 'border-green-200 bg-green-50',
|
||||
headerColor: 'bg-green-100 text-green-800',
|
||||
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: 'Information',
|
||||
description: 'Hinweise und Schulungen',
|
||||
color: 'border-yellow-200 bg-yellow-50',
|
||||
headerColor: 'bg-yellow-100 text-yellow-800',
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
interface MitigationFormData {
|
||||
title: string
|
||||
description: string
|
||||
reduction_type: 'design' | 'protection' | 'information'
|
||||
linked_hazard_ids: string[]
|
||||
}
|
||||
|
||||
function MitigationForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hazards,
|
||||
preselectedType,
|
||||
}: {
|
||||
onSubmit: (data: MitigationFormData) => void
|
||||
onCancel: () => void
|
||||
hazards: Hazard[]
|
||||
preselectedType?: 'design' | 'protection' | 'information'
|
||||
}) {
|
||||
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">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neue Massnahme</h3>
|
||||
<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">Design - Inhaerent sichere Konstruktion</option>
|
||||
<option value="protection">Schutz - Technische Schutzmassnahmen</option>
|
||||
<option value="information">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">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
<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>()
|
||||
|
||||
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()
|
||||
setMitigations(json.mitigations || json || [])
|
||||
}
|
||||
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 })))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 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>
|
||||
<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>
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setPreselectedType(undefined)
|
||||
}}
|
||||
hazards={hazards}
|
||||
preselectedType={preselectedType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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-4`}>
|
||||
{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>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((m) => (
|
||||
<MitigationCard
|
||||
key={m.id}
|
||||
mitigation={m}
|
||||
onVerify={handleVerify}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
512
admin-compliance/app/sdk/iace/[projectId]/monitoring/page.tsx
Normal file
512
admin-compliance/app/sdk/iace/[projectId]/monitoring/page.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface MonitoringEvent {
|
||||
id: string
|
||||
event_type: 'incident' | 'update' | 'drift_alert' | 'regulation_change'
|
||||
title: string
|
||||
description: string
|
||||
severity: 'low' | 'medium' | 'high' | 'critical'
|
||||
status: 'open' | 'investigating' | 'resolved' | 'closed'
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
resolved_by: string | null
|
||||
resolution_notes: string | null
|
||||
}
|
||||
|
||||
const EVENT_TYPE_CONFIG: Record<string, { label: string; color: string; bgColor: string; icon: string }> = {
|
||||
incident: {
|
||||
label: 'Vorfall',
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-100',
|
||||
icon: '🚨',
|
||||
},
|
||||
update: {
|
||||
label: 'Update',
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-100',
|
||||
icon: '🔄',
|
||||
},
|
||||
drift_alert: {
|
||||
label: 'Drift-Warnung',
|
||||
color: 'text-orange-700',
|
||||
bgColor: 'bg-orange-100',
|
||||
icon: '📉',
|
||||
},
|
||||
regulation_change: {
|
||||
label: 'Regulierungsaenderung',
|
||||
color: 'text-purple-700',
|
||||
bgColor: 'bg-purple-100',
|
||||
icon: '📜',
|
||||
},
|
||||
}
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
low: { label: 'Niedrig', color: 'bg-green-100 text-green-700' },
|
||||
medium: { label: 'Mittel', color: 'bg-yellow-100 text-yellow-700' },
|
||||
high: { label: 'Hoch', color: 'bg-orange-100 text-orange-700' },
|
||||
critical: { label: 'Kritisch', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
open: { label: 'Offen', color: 'bg-red-100 text-red-700' },
|
||||
investigating: { label: 'In Untersuchung', color: 'bg-yellow-100 text-yellow-700' },
|
||||
resolved: { label: 'Geloest', color: 'bg-green-100 text-green-700' },
|
||||
closed: { label: 'Geschlossen', color: 'bg-gray-100 text-gray-700' },
|
||||
}
|
||||
|
||||
function EventTypeBadge({ type }: { type: string }) {
|
||||
const config = EVENT_TYPE_CONFIG[type] || EVENT_TYPE_CONFIG.incident
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
|
||||
{config.icon} {config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.low
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.open
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface EventFormData {
|
||||
event_type: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
}
|
||||
|
||||
function EventForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (data: EventFormData) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<EventFormData>({
|
||||
event_type: 'incident',
|
||||
title: '',
|
||||
description: '',
|
||||
severity: 'medium',
|
||||
})
|
||||
|
||||
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">Neues Monitoring-Ereignis</h3>
|
||||
<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. KI-Modell Drift erkannt"
|
||||
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 className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
|
||||
<select
|
||||
value={formData.event_type}
|
||||
onChange={(e) => setFormData({ ...formData, event_type: e.target.value })}
|
||||
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="incident">Vorfall</option>
|
||||
<option value="update">Update</option>
|
||||
<option value="drift_alert">Drift-Warnung</option>
|
||||
<option value="regulation_change">Regulierungsaenderung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schwere</label>
|
||||
<select
|
||||
value={formData.severity}
|
||||
onChange={(e) => setFormData({ ...formData, severity: e.target.value })}
|
||||
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="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
</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={3}
|
||||
placeholder="Beschreiben Sie das Ereignis..."
|
||||
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>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
Ereignis erfassen
|
||||
</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 ResolveModal({
|
||||
event,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
event: MonitoringEvent
|
||||
onSubmit: (id: string, notes: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
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-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Ereignis loesen: {event.title}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Loesung / Massnahmen
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie die durchgefuehrten Massnahmen..."
|
||||
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>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(event.id, notes)}
|
||||
disabled={!notes}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
notes
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Als geloest markieren
|
||||
</button>
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineEvent({
|
||||
event,
|
||||
onResolve,
|
||||
}: {
|
||||
event: MonitoringEvent
|
||||
onResolve: (event: MonitoringEvent) => void
|
||||
}) {
|
||||
const typeConfig = EVENT_TYPE_CONFIG[event.event_type] || EVENT_TYPE_CONFIG.incident
|
||||
const lineColor = event.status === 'resolved' || event.status === 'closed' ? 'bg-green-300' : 'bg-gray-300'
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 pb-8 last:pb-0">
|
||||
{/* Timeline line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${typeConfig.bgColor} flex-shrink-0`}>
|
||||
{typeConfig.icon}
|
||||
</div>
|
||||
<div className={`w-0.5 flex-1 ${lineColor} mt-2`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 -mt-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">{event.title}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<EventTypeBadge type={event.event_type} />
|
||||
<SeverityBadge severity={event.severity} />
|
||||
<StatusBadge status={event.status} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{new Date(event.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">{event.description}</p>
|
||||
)}
|
||||
|
||||
{event.resolution_notes && (
|
||||
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">Loesung:</div>
|
||||
<p className="text-sm text-green-800 dark:text-green-300">{event.resolution_notes}</p>
|
||||
{event.resolved_at && (
|
||||
<div className="text-xs text-green-600 mt-1">
|
||||
Geloest am {new Date(event.resolved_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(event.status === 'open' || event.status === 'investigating') && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => onResolve(event)}
|
||||
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Loesen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [events, setEvents] = useState<MonitoringEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [resolvingEvent, setResolvingEvent] = useState<MonitoringEvent | null>(null)
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setEvents(json.events || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch monitoring events:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: EventFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
await fetchEvents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolve(id: string, notes: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ resolution_notes: notes }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setResolvingEvent(null)
|
||||
await fetchEvents()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEvents = events.filter((e) => {
|
||||
const matchType = !filterType || e.event_type === filterType
|
||||
const matchStatus = !filterStatus || e.status === filterStatus
|
||||
return matchType && matchStatus
|
||||
})
|
||||
|
||||
const openCount = events.filter((e) => e.status === 'open' || e.status === 'investigating').length
|
||||
const resolvedCount = events.filter((e) => e.status === 'resolved' || e.status === 'closed').length
|
||||
|
||||
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-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Monitoring</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Post-Market Surveillance -- Ueberwachung von Vorfaellen, Updates, Drift und Regulierungsaenderungen.
|
||||
</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>
|
||||
Ereignis erfassen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{events.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{events.length}</div>
|
||||
<div className="text-xs text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{openCount}</div>
|
||||
<div className="text-xs text-red-600">Offen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{resolvedCount}</div>
|
||||
<div className="text-xs text-green-600">Geloest</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-orange-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{events.filter((e) => e.severity === 'critical' || e.severity === 'high').length}
|
||||
</div>
|
||||
<div className="text-xs text-orange-600">Hoch/Kritisch</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{events.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="incident">Vorfaelle</option>
|
||||
<option value="update">Updates</option>
|
||||
<option value="drift_alert">Drift-Warnungen</option>
|
||||
<option value="regulation_change">Regulierungsaenderungen</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="investigating">In Untersuchung</option>
|
||||
<option value="resolved">Geloest</option>
|
||||
<option value="closed">Geschlossen</option>
|
||||
</select>
|
||||
<span className="text-sm text-gray-500">
|
||||
{filteredEvents.length} Ereignisse
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<EventForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} />
|
||||
)}
|
||||
|
||||
{/* Resolve Modal */}
|
||||
{resolvingEvent && (
|
||||
<ResolveModal
|
||||
event={resolvingEvent}
|
||||
onSubmit={handleResolve}
|
||||
onClose={() => setResolvingEvent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{filteredEvents.length > 0 ? (
|
||||
<div className="pl-1">
|
||||
{filteredEvents
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map((event) => (
|
||||
<TimelineEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
onResolve={() => setResolvingEvent(event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Keine Monitoring-Ereignisse</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Erfassen Sie Vorfaelle, Software-Updates, KI-Drift-Warnungen und Regulierungsaenderungen
|
||||
im Rahmen der Post-Market Surveillance.
|
||||
</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"
|
||||
>
|
||||
Erstes Ereignis erfassen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
297
admin-compliance/app/sdk/iace/[projectId]/page.tsx
Normal file
297
admin-compliance/app/sdk/iace/[projectId]/page.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface ProjectOverview {
|
||||
id: string
|
||||
machine_name: string
|
||||
machine_type: string
|
||||
manufacturer: string
|
||||
status: string
|
||||
completeness_pct: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
gates: Gate[]
|
||||
risk_summary: {
|
||||
critical: number
|
||||
high: number
|
||||
medium: number
|
||||
low: number
|
||||
total: number
|
||||
}
|
||||
component_count: number
|
||||
hazard_count: number
|
||||
mitigation_count: number
|
||||
}
|
||||
|
||||
interface Gate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
passed: boolean | null
|
||||
required: boolean
|
||||
}
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: '/components', label: 'Komponenten verwalten', icon: 'cube', description: 'SW/FW/AI/HMI Baum bearbeiten' },
|
||||
{ href: '/classification', label: 'Klassifikation pruefen', icon: 'tag', description: 'AI Act, MVO, CRA, NIS2' },
|
||||
{ href: '/hazards', label: 'Hazard Log oeffnen', icon: 'warning', description: 'Gefaehrdungen und Risiken' },
|
||||
{ href: '/mitigations', label: 'Massnahmen planen', icon: 'shield', description: 'Design, Schutz, Information' },
|
||||
{ href: '/verification', label: 'Verifikationsplan', icon: 'check', description: 'Nachweise zuordnen' },
|
||||
{ href: '/evidence', label: 'Nachweise hochladen', icon: 'document', description: 'Dokumente und Berichte' },
|
||||
{ href: '/tech-file', label: 'CE-Akte generieren', icon: 'folder', description: 'Technische Dokumentation' },
|
||||
{ href: '/monitoring', label: 'Monitoring', icon: 'activity', description: 'Post-Market Ueberwachung' },
|
||||
]
|
||||
|
||||
function GateIndicator({ gate }: { gate: Gate }) {
|
||||
const color = gate.passed === true
|
||||
? 'bg-green-500'
|
||||
: gate.passed === false
|
||||
? 'bg-red-500'
|
||||
: 'bg-gray-300'
|
||||
|
||||
const textColor = gate.passed === true
|
||||
? 'text-green-700'
|
||||
: gate.passed === false
|
||||
? 'text-red-700'
|
||||
: 'text-gray-500'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className={`w-3 h-3 rounded-full ${color} flex-shrink-0`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium ${textColor}`}>{gate.name}</div>
|
||||
<div className="text-xs text-gray-400">{gate.description}</div>
|
||||
</div>
|
||||
{gate.required && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">Pflicht</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskGauge({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
|
||||
const pct = max > 0 ? Math.round((value / max) * 100) : 0
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="relative w-20 h-20 mx-auto">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="#E5E7EB"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeDasharray={`${pct}, 100`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_WORKFLOW = [
|
||||
{ key: 'draft', label: 'Entwurf' },
|
||||
{ key: 'in_progress', label: 'In Bearbeitung' },
|
||||
{ key: 'review', label: 'In Pruefung' },
|
||||
{ key: 'approved', label: 'Freigegeben' },
|
||||
]
|
||||
|
||||
export default function ProjectOverviewPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [project, setProject] = useState<ProjectOverview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchProject()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchProject() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setProject(json)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch project:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Projekt nicht gefunden</h2>
|
||||
<Link href="/sdk/iace" className="mt-2 text-purple-600 hover:text-purple-700">
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentStatusIndex = STATUS_WORKFLOW.findIndex((s) => s.key === project.status)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{project.machine_name}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{project.machine_type} {project.manufacturer ? `-- ${project.manufacturer}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Workflow */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Projektstatus</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{STATUS_WORKFLOW.map((step, index) => (
|
||||
<React.Fragment key={step.key}>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
index <= currentStatusIndex
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{index < currentStatusIndex ? (
|
||||
<svg className="w-4 h-4 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : index === currentStatusIndex ? (
|
||||
<div className="w-2 h-2 rounded-full bg-purple-600" />
|
||||
) : (
|
||||
<div className="w-2 h-2 rounded-full bg-gray-300" />
|
||||
)}
|
||||
{step.label}
|
||||
</div>
|
||||
{index < STATUS_WORKFLOW.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 ${index < currentStatusIndex ? 'bg-purple-300' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Machine Info */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Maschineninformationen</h2>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Maschinenname</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-white">{project.machine_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Typ</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-white">{project.machine_type || '--'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Hersteller</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-white">{project.manufacturer || '--'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Vollstaendigkeit</dt>
|
||||
<dd className="mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${project.completeness_pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-600">{project.completeness_pct}%</span>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Risk Summary */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Risikozusammenfassung</h2>
|
||||
<div className="flex items-center justify-around">
|
||||
<RiskGauge label="Kritisch" value={project.risk_summary.critical} max={project.risk_summary.total || 1} color="#EF4444" />
|
||||
<RiskGauge label="Hoch" value={project.risk_summary.high} max={project.risk_summary.total || 1} color="#F97316" />
|
||||
<RiskGauge label="Mittel" value={project.risk_summary.medium} max={project.risk_summary.total || 1} color="#EAB308" />
|
||||
<RiskGauge label="Niedrig" value={project.risk_summary.low} max={project.risk_summary.total || 1} color="#22C55E" />
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{project.component_count}</div>
|
||||
<div className="text-xs text-gray-500">Komponenten</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{project.hazard_count}</div>
|
||||
<div className="text-xs text-gray-500">Gefaehrdungen</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{project.mitigation_count}</div>
|
||||
<div className="text-xs text-gray-500">Massnahmen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completeness Gates */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Completeness Gates</h2>
|
||||
<div className="space-y-1">
|
||||
{project.gates && project.gates.length > 0 ? (
|
||||
project.gates.map((gate) => <GateIndicator key={gate.id} gate={gate} />)
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Keine Gates definiert</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Schnellzugriff</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={`/sdk/iace/${projectId}${action.href}`}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md hover:border-purple-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-50 dark:bg-purple-900/30 rounded-lg flex items-center justify-center text-purple-600 group-hover:bg-purple-100 transition-colors flex-shrink-0">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{action.label}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{action.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
416
admin-compliance/app/sdk/iace/[projectId]/tech-file/page.tsx
Normal file
416
admin-compliance/app/sdk/iace/[projectId]/tech-file/page.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface TechFileSection {
|
||||
id: string
|
||||
section_type: string
|
||||
title: string
|
||||
description: string
|
||||
content: string | null
|
||||
status: 'empty' | 'draft' | 'generated' | 'reviewed' | 'approved'
|
||||
generated_at: string | null
|
||||
approved_at: string | null
|
||||
approved_by: string | null
|
||||
required: boolean
|
||||
}
|
||||
|
||||
const SECTION_TYPES: Record<string, { icon: string; description: string }> = {
|
||||
risk_assessment_report: {
|
||||
icon: '📊',
|
||||
description: 'Zusammenfassung der Risikobeurteilung mit allen bewerteten Gefaehrdungen',
|
||||
},
|
||||
hazard_log: {
|
||||
icon: '⚠️',
|
||||
description: 'Vollstaendiges Gefaehrdungsprotokoll mit S/E/P-Bewertungen',
|
||||
},
|
||||
component_list: {
|
||||
icon: '🔧',
|
||||
description: 'Verzeichnis aller sicherheitsrelevanten Komponenten',
|
||||
},
|
||||
classification_report: {
|
||||
icon: '📋',
|
||||
description: 'Regulatorische Klassifikation (AI Act, MVO, CRA, NIS2)',
|
||||
},
|
||||
mitigation_report: {
|
||||
icon: '🛡️',
|
||||
description: 'Uebersicht aller Schutzmassnahmen nach 3-Stufen-Verfahren',
|
||||
},
|
||||
verification_report: {
|
||||
icon: '✅',
|
||||
description: 'Verifikationsplan und Ergebnisse aller Nachweisverfahren',
|
||||
},
|
||||
evidence_index: {
|
||||
icon: '📎',
|
||||
description: 'Index aller Nachweisdokumente mit Verknuepfungen',
|
||||
},
|
||||
declaration_of_conformity: {
|
||||
icon: '📜',
|
||||
description: 'EU-Konformitaetserklaerung',
|
||||
},
|
||||
instructions_for_use: {
|
||||
icon: '📖',
|
||||
description: 'Sicherheitshinweise fuer Betriebsanleitung',
|
||||
},
|
||||
monitoring_plan: {
|
||||
icon: '📡',
|
||||
description: 'Post-Market Surveillance Plan',
|
||||
},
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
empty: { label: 'Leer', color: 'text-gray-500', bgColor: 'bg-gray-100' },
|
||||
draft: { label: 'Entwurf', color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
|
||||
generated: { label: 'Generiert', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||
reviewed: { label: 'Geprueft', color: 'text-orange-700', bgColor: 'bg-orange-100' },
|
||||
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.empty
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionViewer({
|
||||
section,
|
||||
onClose,
|
||||
onApprove,
|
||||
onSave,
|
||||
}: {
|
||||
section: TechFileSection
|
||||
onClose: () => void
|
||||
onApprove: (id: string) => void
|
||||
onSave: (id: string, content: string) => void
|
||||
}) {
|
||||
const [editedContent, setEditedContent] = useState(section.content || '')
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{SECTION_TYPES[section.section_type]?.icon || '📄'}</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{section.title}</h3>
|
||||
<StatusBadge status={section.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!editing && section.content && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-sm px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
)}
|
||||
{section.status !== 'approved' && section.content && !editing && (
|
||||
<button
|
||||
onClick={() => onApprove(section.id)}
|
||||
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Kein Inhalt vorhanden. Klicken Sie "Generieren" um den Abschnitt zu erstellen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TechFilePage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [sections, setSections] = useState<TechFileSection[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generatingSection, setGeneratingSection] = useState<string | null>(null)
|
||||
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSections()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchSections() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSections(json.sections || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tech file sections:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate(sectionId: string) {
|
||||
setGeneratingSection(sectionId)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/${sectionId}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchSections()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to generate section:', err)
|
||||
} finally {
|
||||
setGeneratingSection(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(sectionId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/${sectionId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchSections()
|
||||
if (viewingSection && viewingSection.id === sectionId) {
|
||||
const updated = sections.find((s) => s.id === sectionId)
|
||||
if (updated) setViewingSection({ ...updated, status: 'approved' })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to approve section:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(sectionId: string, content: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/${sectionId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchSections()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save section:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportZip() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/export`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `CE-Akte-${projectId}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export:', err)
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const approvedCount = sections.filter((s) => s.status === 'approved').length
|
||||
const requiredCount = sections.filter((s) => s.required).length
|
||||
const requiredApproved = sections.filter((s) => s.required && s.status === 'approved').length
|
||||
const allRequiredApproved = requiredApproved === requiredCount && requiredCount > 0
|
||||
|
||||
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-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">CE-Akte (Technical File)</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Technische Dokumentation gemaess Maschinenverordnung Anhang IV. Generieren, pruefen und freigeben
|
||||
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" />
|
||||
</svg>
|
||||
)}
|
||||
ZIP exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Fortschritt: {approvedCount} von {sections.length} Abschnitten freigegeben
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
Pflicht: {requiredApproved}/{requiredCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-purple-600 h-2.5 rounded-full transition-all"
|
||||
style={{ width: `${sections.length > 0 ? (approvedCount / sections.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Viewer */}
|
||||
{viewingSection && (
|
||||
<SectionViewer
|
||||
section={viewingSection}
|
||||
onClose={() => setViewingSection(null)}
|
||||
onApprove={handleApprove}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sections List */}
|
||||
<div className="space-y-3">
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl flex-shrink-0">
|
||||
{SECTION_TYPES[section.section_type]?.icon || '📄'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{section.title}</h3>
|
||||
<StatusBadge status={section.status} />
|
||||
{section.required && (
|
||||
<span className="text-xs text-red-500 font-medium">Pflicht</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{SECTION_TYPES[section.section_type]?.description || section.description}
|
||||
</p>
|
||||
{section.approved_at && (
|
||||
<span className="text-xs text-green-600 mt-0.5 block">
|
||||
Freigegeben am {new Date(section.approved_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{section.content && (
|
||||
<button
|
||||
onClick={() => setViewingSection(section)}
|
||||
className="text-sm px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleGenerate(section.id)}
|
||||
disabled={generatingSection === section.id}
|
||||
className="text-sm px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
||||
>
|
||||
{generatingSection === section.id ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
|
||||
Generiert...
|
||||
</>
|
||||
) : (
|
||||
'Generieren'
|
||||
)}
|
||||
</button>
|
||||
{section.content && section.status !== 'approved' && (
|
||||
<button
|
||||
onClick={() => handleApprove(section.id)}
|
||||
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sections.length === 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Keine Abschnitte vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Die CE-Akte wird automatisch strukturiert, sobald Komponenten und Gefaehrdungen erfasst sind.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
483
admin-compliance/app/sdk/iace/[projectId]/verification/page.tsx
Normal file
483
admin-compliance/app/sdk/iace/[projectId]/verification/page.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
interface VerificationItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
method: string
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||
result: string | null
|
||||
linked_hazard_id: string | null
|
||||
linked_hazard_name: string | null
|
||||
linked_mitigation_id: string | null
|
||||
linked_mitigation_name: string | null
|
||||
completed_at: string | null
|
||||
completed_by: string | null
|
||||
created_at: 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' },
|
||||
]
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
|
||||
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
|
||||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
||||
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface VerificationFormData {
|
||||
title: string
|
||||
description: string
|
||||
method: string
|
||||
linked_hazard_id: string
|
||||
linked_mitigation_id: string
|
||||
}
|
||||
|
||||
function VerificationForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hazards,
|
||||
mitigations,
|
||||
}: {
|
||||
onSubmit: (data: VerificationFormData) => void
|
||||
onCancel: () => void
|
||||
hazards: { id: string; name: string }[]
|
||||
mitigations: { id: string; title: string }[]
|
||||
}) {
|
||||
const [formData, setFormData] = useState<VerificationFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
method: 'test',
|
||||
linked_hazard_id: '',
|
||||
linked_mitigation_id: '',
|
||||
})
|
||||
|
||||
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">Neues Verifikationselement</h3>
|
||||
<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. Funktionstest Lichtvorhang"
|
||||
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">Methode</label>
|
||||
<select
|
||||
value={formData.method}
|
||||
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
|
||||
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"
|
||||
>
|
||||
{VERIFICATION_METHODS.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</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="Beschreiben Sie den Verifikationsschritt..."
|
||||
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 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">Verknuepfte Gefaehrdung</label>
|
||||
<select
|
||||
value={formData.linked_hazard_id}
|
||||
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
|
||||
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="">-- Keine --</option>
|
||||
{hazards.map((h) => (
|
||||
<option key={h.id} value={h.id}>{h.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
|
||||
<select
|
||||
value={formData.linked_mitigation_id}
|
||||
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
|
||||
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="">-- Keine --</option>
|
||||
{mitigations.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</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 CompleteModal({
|
||||
item,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
item: VerificationItem
|
||||
onSubmit: (id: string, result: string, passed: boolean) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [result, setResult] = useState('')
|
||||
const [passed, setPassed] = useState(true)
|
||||
|
||||
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-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Verifikation abschliessen: {item.title}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
|
||||
<textarea
|
||||
value={result}
|
||||
onChange={(e) => setResult(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
|
||||
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-2">Bewertung</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setPassed(true)}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
passed
|
||||
? 'border-green-400 bg-green-50 text-green-700'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Bestanden
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPassed(false)}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
!passed
|
||||
? 'border-red-400 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Nicht bestanden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onSubmit(item.id, result, passed)}
|
||||
disabled={!result}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
result
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VerificationPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const [items, setItems] = useState<VerificationItem[]>([])
|
||||
const [hazards, setHazards] = useState<{ id: string; name: string }[]>([])
|
||||
const [mitigations, setMitigations] = useState<{ id: string; title: string }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [projectId])
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [verRes, hazRes, mitRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
])
|
||||
if (verRes.ok) {
|
||||
const json = await verRes.json()
|
||||
setItems(json.verifications || json || [])
|
||||
}
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name })))
|
||||
}
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title })))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: VerificationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowForm(false)
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add verification:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ result, passed }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setCompletingItem(null)
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to complete verification:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Verifikation wirklich loeschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete verification:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const completed = items.filter((i) => i.status === 'completed').length
|
||||
const failed = items.filter((i) => i.status === 'failed').length
|
||||
const pending = items.filter((i) => i.status === 'pending' || i.status === 'in_progress').length
|
||||
|
||||
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">Verifikationsplan</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
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>
|
||||
|
||||
{/* Stats */}
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{items.length}</div>
|
||||
<div className="text-xs text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{completed}</div>
|
||||
<div className="text-xs text-green-600">Abgeschlossen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{failed}</div>
|
||||
<div className="text-xs text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{pending}</div>
|
||||
<div className="text-xs text-yellow-600">Ausstehend</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<VerificationForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setShowForm(false)}
|
||||
hazards={hazards}
|
||||
mitigations={mitigations}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Complete Modal */}
|
||||
{completingItem && (
|
||||
<CompleteModal
|
||||
item={completingItem}
|
||||
onSubmit={handleComplete}
|
||||
onClose={() => setCompletingItem(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{items.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{VERIFICATION_METHODS.find((m) => m.value === item.method)?.label || item.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{item.status !== 'completed' && item.status !== 'failed' && (
|
||||
<button
|
||||
onClick={() => setCompletingItem(item)}
|
||||
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"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Verifikationsplan vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
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>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
admin-compliance/app/sdk/iace/layout.tsx
Normal file
141
admin-compliance/app/sdk/iace/layout.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useParams } from 'next/navigation'
|
||||
|
||||
const IACE_NAV_ITEMS = [
|
||||
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
||||
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
||||
{ id: 'classification', label: 'Klassifikation', href: '/classification', icon: 'tag' },
|
||||
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
||||
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
|
||||
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
|
||||
{ id: 'evidence', label: 'Nachweise', href: '/evidence', icon: 'document' },
|
||||
{ id: 'tech-file', label: 'CE-Akte', href: '/tech-file', icon: 'folder' },
|
||||
{ id: 'monitoring', label: 'Monitoring', href: '/monitoring', icon: 'activity' },
|
||||
]
|
||||
|
||||
function NavIcon({ icon, className }: { icon: string; className?: string }) {
|
||||
const cls = className || 'w-5 h-5'
|
||||
switch (icon) {
|
||||
case 'grid':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
)
|
||||
case 'cube':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
)
|
||||
case 'tag':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
)
|
||||
case 'warning':
|
||||
return (
|
||||
<svg className={cls} 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>
|
||||
)
|
||||
case 'shield':
|
||||
return (
|
||||
<svg className={cls} 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>
|
||||
)
|
||||
case 'check':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
case 'document':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
)
|
||||
case 'folder':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
)
|
||||
case 'activity':
|
||||
return (
|
||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function IACELayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const projectId = params?.projectId as string | undefined
|
||||
|
||||
const basePath = projectId ? `/sdk/iace/${projectId}` : ''
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (!projectId) return false
|
||||
const fullPath = `${basePath}${href}`
|
||||
if (href === '') {
|
||||
return pathname === fullPath
|
||||
}
|
||||
return pathname.startsWith(fullPath)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-screen">
|
||||
{/* Sidebar - only show when inside a project */}
|
||||
{projectId && (
|
||||
<aside className="w-[200px] bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<Link
|
||||
href="/sdk/iace"
|
||||
className="text-xs text-purple-600 hover:text-purple-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Alle Projekte
|
||||
</Link>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mt-2">
|
||||
CE-Compliance
|
||||
</h2>
|
||||
</div>
|
||||
<nav className="p-2 space-y-0.5">
|
||||
{IACE_NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`${basePath}${item.href}`}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive(item.href)
|
||||
? 'bg-purple-50 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<NavIcon icon={item.icon} className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
337
admin-compliance/app/sdk/iace/page.tsx
Normal file
337
admin-compliance/app/sdk/iace/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface IACEProject {
|
||||
id: string
|
||||
machine_name: string
|
||||
machine_type: string
|
||||
manufacturer: string
|
||||
status: string
|
||||
completeness_pct: number
|
||||
risk_summary: {
|
||||
critical: number
|
||||
high: number
|
||||
medium: number
|
||||
low: number
|
||||
}
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
review: 'bg-yellow-100 text-yellow-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
review: 'In Pruefung',
|
||||
approved: 'Freigegeben',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.draft}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CompletenessBar({ pct }: { pct: number }) {
|
||||
const color = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : pct >= 25 ? 'bg-orange-500' : 'bg-red-500'
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div className={`${color} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-600 w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskDots({ summary }: { summary: IACEProject['risk_summary'] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{summary.critical > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
|
||||
<span className="text-gray-600">{summary.critical}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.high > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-orange-500" />
|
||||
<span className="text-gray-600">{summary.high}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.medium > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500" />
|
||||
<span className="text-gray-600">{summary.medium}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.low > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
|
||||
<span className="text-gray-600">{summary.low}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.critical === 0 && summary.high === 0 && summary.medium === 0 && summary.low === 0 && (
|
||||
<span className="text-gray-400">Keine Risiken</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectCard({ project }: { project: IACEProject }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/sdk/iace/${project.id}`}
|
||||
className="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{project.machine_name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{project.machine_type}</p>
|
||||
</div>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Vollstaendigkeit</div>
|
||||
<CompletenessBar pct={project.completeness_pct} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Risiken</div>
|
||||
<RiskDots summary={project.risk_summary} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-400 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<span>Erstellt: {new Date(project.created_at).toLocaleDateString('de-DE')}</span>
|
||||
<span>Aktualisiert: {new Date(project.updated_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function IACEDashboardPage() {
|
||||
const [projects, setProjects] = useState<IACEProject[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
machine_name: '',
|
||||
machine_type: '',
|
||||
manufacturer: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
}, [])
|
||||
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/projects')
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setProjects(json.projects || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch IACE projects:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateProject() {
|
||||
if (!formData.machine_name) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
if (res.ok) {
|
||||
setShowCreateForm(false)
|
||||
setFormData({ machine_name: '', machine_type: '', manufacturer: '' })
|
||||
await fetchProjects()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInitFromProfile(projectId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/init-from-profile`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchProjects()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to init from profile:', err)
|
||||
}
|
||||
}
|
||||
|
||||
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 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
CE-Compliance (IACE)
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Industrial AI Compliance Engine -- Durchgaengige CE-Konformitaet fuer Maschinen und Anlagen
|
||||
mit KI-Komponenten. Verwalten Sie Risikobeurteilungen, Hazard Logs und technische
|
||||
Dokumentation gemaess Maschinenverordnung, AI Act, CRA und NIS2.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
||||
>
|
||||
<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>
|
||||
Neues Projekt erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<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">
|
||||
Neues CE-Projekt anlegen
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Maschinenname *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.machine_name}
|
||||
onChange={(e) => setFormData({ ...formData, machine_name: e.target.value })}
|
||||
placeholder="z.B. Schweissroboter SR-500"
|
||||
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">
|
||||
Maschinentyp
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.machine_type}
|
||||
onChange={(e) => setFormData({ ...formData, machine_type: e.target.value })}
|
||||
placeholder="z.B. Industrieroboter"
|
||||
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">
|
||||
Hersteller
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.manufacturer}
|
||||
onChange={(e) => setFormData({ ...formData, manufacturer: e.target.value })}
|
||||
placeholder="z.B. Acme Robotics GmbH"
|
||||
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>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
disabled={!formData.machine_name || creating}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.machine_name && !creating
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{creating ? 'Wird erstellt...' : 'Projekt erstellen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project List */}
|
||||
{projects.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Projekte ({projects.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{projects.length === 0 && !showCreateForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Noch keine CE-Projekte vorhanden
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400 max-w-lg mx-auto">
|
||||
Die IACE (Industrial AI Compliance Engine) begleitet Sie Schritt fuer Schritt durch den
|
||||
gesamten CE-Konformitaetsprozess. Von der Komponentenerfassung ueber die Risikobeurteilung
|
||||
bis hin zur fertigen CE-Akte -- alles in einem Werkzeug. Unterstuetzt werden Maschinenverordnung
|
||||
(2023/1230), AI Act, Cyber Resilience Act und NIS2.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
|
||||
>
|
||||
Erstes Projekt erstellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleInitFromProfile('new')}
|
||||
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors font-medium"
|
||||
>
|
||||
Aus Unternehmensprofil initialisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user