feat(iace): sync IACE frontend, API routes, and scope engine updates from breakpilot-pwa
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 35s
CI / test-python-backend-compliance (push) Successful in 29s
CI / test-python-document-crawler (push) Successful in 24s
CI / test-python-dsms-gateway (push) Successful in 21s

- Add IACE project pages (classification, evidence, hazards, mitigations, monitoring, tech-file, verification)
- Add IACE API catch-all route
- Update compliance-scope-engine with IACE AI Act product triggers
- Update compliance-scope-types, navigation, roles, and sidebar for IACE
- Update company-profile page
This commit is contained in:
Benjamin Boenisch
2026-02-25 23:03:03 +01:00
parent 5314db49e2
commit 03708d9e5b
18 changed files with 5565 additions and 110 deletions

View File

@@ -9,10 +9,19 @@ import {
TargetMarket,
CompanySize,
LegalForm,
MachineBuilderProfile,
MachineProductType,
AIIntegrationType,
HumanOversightLevel,
CriticalSector,
BUSINESS_MODEL_LABELS,
OFFERING_TYPE_LABELS,
TARGET_MARKET_LABELS,
COMPANY_SIZE_LABELS,
MACHINE_PRODUCT_TYPE_LABELS,
AI_INTEGRATION_TYPE_LABELS,
HUMAN_OVERSIGHT_LABELS,
CRITICAL_SECTOR_LABELS,
SDKCoverageAssessment,
} from '@/lib/sdk/types'
@@ -20,14 +29,26 @@ import {
// WIZARD STEPS
// =============================================================================
const WIZARD_STEPS = [
const BASE_WIZARD_STEPS = [
{ id: 1, name: 'Basisinfos', description: 'Firmenname und Rechtsform' },
{ id: 2, name: 'Geschäftsmodell', description: 'B2B, B2C und Angebote' },
{ id: 3, name: 'Firmengröße', description: 'Mitarbeiter und Umsatz' },
{ id: 4, name: 'Standorte', description: 'Hauptsitz und Zielmärkte' },
{ id: 2, name: 'Geschaeftsmodell', description: 'B2B, B2C und Angebote' },
{ id: 3, name: 'Firmengroesse', description: 'Mitarbeiter und Umsatz' },
{ id: 4, name: 'Standorte', description: 'Hauptsitz und Zielmaerkte' },
{ id: 5, name: 'Datenschutz', description: 'Rollen und KI-Nutzung' },
]
const MACHINE_BUILDER_STEP = { id: 6, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
function getWizardSteps(industry: string) {
if (isMachineBuilderIndustry(industry)) {
return [...BASE_WIZARD_STEPS, MACHINE_BUILDER_STEP]
}
return BASE_WIZARD_STEPS
}
// Keep WIZARD_STEPS for backwards compat in static references
const WIZARD_STEPS = BASE_WIZARD_STEPS
// =============================================================================
// LEGAL FORMS
// =============================================================================
@@ -61,9 +82,25 @@ const INDUSTRIES = [
'Produktion / Industrie',
'Logistik / Transport',
'Immobilien',
'Maschinenbau',
'Anlagenbau',
'Automatisierung',
'Robotik',
'Messtechnik',
'Sonstige',
]
const MACHINE_BUILDER_INDUSTRIES = [
'Maschinenbau',
'Anlagenbau',
'Automatisierung',
'Robotik',
'Messtechnik',
]
const isMachineBuilderIndustry = (industry: string) =>
MACHINE_BUILDER_INDUSTRIES.includes(industry)
// =============================================================================
// HELPER: ASSESS SDK COVERAGE
// =============================================================================
@@ -504,6 +541,423 @@ function StepDataProtection({
)
}
// =============================================================================
// STEP 6: PRODUKT & MASCHINE (nur fuer Maschinenbauer)
// =============================================================================
const EMPTY_MACHINE_BUILDER: MachineBuilderProfile = {
productTypes: [],
productDescription: '',
productPride: '',
containsSoftware: false,
containsFirmware: false,
containsAI: false,
aiIntegrationType: [],
hasSafetyFunction: false,
safetyFunctionDescription: '',
autonomousBehavior: false,
humanOversightLevel: 'full',
isNetworked: false,
hasRemoteAccess: false,
hasOTAUpdates: false,
updateMechanism: '',
exportMarkets: [],
criticalSectorClients: false,
criticalSectors: [],
oemClients: false,
ceMarkingRequired: false,
existingCEProcess: false,
hasRiskAssessment: false,
}
function StepMachineBuilder({
data,
onChange,
}: {
data: Partial<CompanyProfile>
onChange: (updates: Partial<CompanyProfile>) => void
}) {
const mb = data.machineBuilder || EMPTY_MACHINE_BUILDER
const updateMB = (updates: Partial<MachineBuilderProfile>) => {
onChange({ machineBuilder: { ...mb, ...updates } })
}
const toggleProductType = (type: MachineProductType) => {
const current = mb.productTypes || []
if (current.includes(type)) {
updateMB({ productTypes: current.filter(t => t !== type) })
} else {
updateMB({ productTypes: [...current, type] })
}
}
const toggleAIType = (type: AIIntegrationType) => {
const current = mb.aiIntegrationType || []
if (current.includes(type)) {
updateMB({ aiIntegrationType: current.filter(t => t !== type) })
} else {
updateMB({ aiIntegrationType: [...current, type] })
}
}
const toggleCriticalSector = (sector: CriticalSector) => {
const current = mb.criticalSectors || []
if (current.includes(sector)) {
updateMB({ criticalSectors: current.filter(s => s !== sector) })
} else {
updateMB({ criticalSectors: [...current, sector] })
}
}
return (
<div className="space-y-8">
{/* Block 1: Erzaehlen Sie uns von Ihrer Anlage */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-1">Erzaehlen Sie uns von Ihrer Anlage</h3>
<p className="text-sm text-gray-500 mb-4">
Je besser wir Ihr Produkt verstehen, desto praeziser koennen wir die relevanten Vorschriften identifizieren.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Was baut Ihr Unternehmen? <span className="text-red-500">*</span>
</label>
<textarea
value={mb.productDescription}
onChange={e => updateMB({ productDescription: e.target.value })}
placeholder="z.B. Wir bauen automatisierte Pruefstaende fuer die Qualitaetskontrolle in der Automobilindustrie..."
rows={3}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Was macht Ihre Anlage besonders?
</label>
<textarea
value={mb.productPride}
onChange={e => updateMB({ productPride: e.target.value })}
placeholder="z.B. Unsere Anlage kann 500 Teile/Stunde mit 99.9% Erkennungsrate pruefen..."
rows={2}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Produkttyp <span className="text-gray-400">(Mehrfachauswahl)</span>
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{Object.entries(MACHINE_PRODUCT_TYPE_LABELS).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => toggleProductType(value as MachineProductType)}
className={`px-4 py-3 rounded-lg border-2 text-sm font-medium transition-all ${
mb.productTypes.includes(value as MachineProductType)
? 'border-purple-500 bg-purple-50 text-purple-700'
: 'border-gray-200 hover:border-purple-300 text-gray-700'
}`}
>
{label}
</button>
))}
</div>
</div>
</div>
</div>
{/* Block 2: Software & KI */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Software & KI in Ihrem Produkt</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ key: 'containsSoftware', label: 'Enthaelt Software', desc: 'Anwendungssoftware in der Maschine' },
{ key: 'containsFirmware', label: 'Enthaelt Firmware', desc: 'Embedded Software / Steuerung' },
{ key: 'containsAI', label: 'Enthaelt KI/ML', desc: 'Kuenstliche Intelligenz / Machine Learning' },
].map(item => (
<label
key={item.key}
className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
(mb as any)[item.key]
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
}`}
>
<input
type="checkbox"
checked={(mb as any)[item.key] ?? false}
onChange={e => updateMB({ [item.key]: e.target.checked } as any)}
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">{item.label}</div>
<div className="text-xs text-gray-500">{item.desc}</div>
</div>
</label>
))}
</div>
{mb.containsAI && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Art der KI-Integration
</label>
<div className="grid grid-cols-2 gap-3">
{Object.entries(AI_INTEGRATION_TYPE_LABELS).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => toggleAIType(value as AIIntegrationType)}
className={`px-4 py-2 rounded-lg border text-sm transition-all ${
mb.aiIntegrationType.includes(value as AIIntegrationType)
? 'border-purple-500 bg-purple-50 text-purple-700'
: 'border-gray-200 hover:border-purple-300 text-gray-700'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
mb.hasSafetyFunction ? 'border-red-400 bg-red-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<input
type="checkbox"
checked={mb.hasSafetyFunction}
onChange={e => updateMB({ hasSafetyFunction: e.target.checked })}
className="mt-1 w-5 h-5 text-red-600 rounded focus:ring-red-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">Sicherheitsrelevante Funktion</div>
<div className="text-xs text-gray-500">KI/SW hat sicherheitsrelevante Funktion</div>
</div>
</label>
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
mb.autonomousBehavior ? 'border-amber-400 bg-amber-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<input
type="checkbox"
checked={mb.autonomousBehavior}
onChange={e => updateMB({ autonomousBehavior: e.target.checked })}
className="mt-1 w-5 h-5 text-amber-600 rounded focus:ring-amber-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">Autonomes Verhalten</div>
<div className="text-xs text-gray-500">System lernt oder handelt eigenstaendig</div>
</div>
</label>
</div>
{mb.hasSafetyFunction && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Beschreibung der Sicherheitsfunktion
</label>
<textarea
value={mb.safetyFunctionDescription}
onChange={e => updateMB({ safetyFunctionDescription: e.target.value })}
placeholder="z.B. KI-Vision ueberwacht den Schutzbereich und stoppt den Roboter bei Personenerkennung..."
rows={2}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Human Oversight Level
</label>
<select
value={mb.humanOversightLevel}
onChange={e => updateMB({ humanOversightLevel: e.target.value as HumanOversightLevel })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{Object.entries(HUMAN_OVERSIGHT_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
</div>
</div>
{/* Block 3: Konnektivitaet & Updates */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Konnektivitaet & Updates</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
{[
{ key: 'isNetworked', label: 'Vernetzt', desc: 'Maschine ist mit Netzwerk verbunden' },
{ key: 'hasRemoteAccess', label: 'Remote-Zugriff', desc: 'Fernwartung / Remote-Zugang' },
{ key: 'hasOTAUpdates', label: 'OTA-Updates', desc: 'Drahtlose Software-/Firmware-Updates' },
].map(item => (
<label
key={item.key}
className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
(mb as any)[item.key]
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
}`}
>
<input
type="checkbox"
checked={(mb as any)[item.key] ?? false}
onChange={e => updateMB({ [item.key]: e.target.checked } as any)}
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">{item.label}</div>
<div className="text-xs text-gray-500">{item.desc}</div>
</div>
</label>
))}
</div>
{(mb.hasOTAUpdates || mb.hasRemoteAccess) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Wie werden Updates eingespielt?
</label>
<input
type="text"
value={mb.updateMechanism}
onChange={e => updateMB({ updateMechanism: e.target.value })}
placeholder="z.B. VPN-gesicherter Remote-Zugang mit manueller Freigabe..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
)}
</div>
{/* Block 4: Markt & Kunden */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Markt & Kunden</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
mb.criticalSectorClients ? 'border-red-400 bg-red-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<input
type="checkbox"
checked={mb.criticalSectorClients}
onChange={e => updateMB({ criticalSectorClients: e.target.checked })}
className="mt-1 w-5 h-5 text-red-600 rounded focus:ring-red-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">Liefert an KRITIS-Betreiber</div>
<div className="text-xs text-gray-500">Kunden in kritischer Infrastruktur</div>
</div>
</label>
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
mb.oemClients ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<input
type="checkbox"
checked={mb.oemClients}
onChange={e => updateMB({ oemClients: e.target.checked })}
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">OEM-Zulieferer</div>
<div className="text-xs text-gray-500">Liefern Komponenten an andere Hersteller</div>
</div>
</label>
</div>
{mb.criticalSectorClients && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Kritische Sektoren Ihrer Kunden
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{Object.entries(CRITICAL_SECTOR_LABELS).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => toggleCriticalSector(value as CriticalSector)}
className={`px-3 py-2 rounded-lg border text-sm transition-all ${
mb.criticalSectors.includes(value as CriticalSector)
? 'border-red-400 bg-red-50 text-red-700'
: 'border-gray-200 hover:border-gray-300 text-gray-700'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
mb.ceMarkingRequired ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<input
type="checkbox"
checked={mb.ceMarkingRequired}
onChange={e => updateMB({ ceMarkingRequired: e.target.checked })}
className="mt-1 w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">CE-Kennzeichnung erforderlich</div>
<div className="text-xs text-gray-500">Produkt benoetigt CE-Zertifizierung</div>
</div>
</label>
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
mb.existingCEProcess ? 'border-green-400 bg-green-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<input
type="checkbox"
checked={mb.existingCEProcess}
onChange={e => updateMB({ existingCEProcess: e.target.checked })}
className="mt-1 w-5 h-5 text-green-600 rounded focus:ring-green-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">Bestehender CE-Prozess</div>
<div className="text-xs text-gray-500">Bereits ein CE-Verfahren etabliert</div>
</div>
</label>
</div>
{mb.ceMarkingRequired && (
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${
mb.hasRiskAssessment ? 'border-green-400 bg-green-50' : 'border-red-400 bg-red-50'
}`}>
<input
type="checkbox"
checked={mb.hasRiskAssessment}
onChange={e => updateMB({ hasRiskAssessment: e.target.checked })}
className="mt-1 w-5 h-5 text-green-600 rounded focus:ring-green-500"
/>
<div>
<div className="font-medium text-gray-900 text-sm">Bestehende Risikobeurteilung</div>
<div className="text-xs text-gray-500">
{mb.hasRiskAssessment
? 'Risikobeurteilung vorhanden'
: 'Keine bestehende Risikobeurteilung - IACE hilft Ihnen dabei!'}
</div>
</div>
</label>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// COVERAGE ASSESSMENT COMPONENT
// =============================================================================
@@ -671,6 +1125,11 @@ export default function CompanyProfilePage() {
completedAt: null,
})
const showMachineBuilderStep = isMachineBuilderIndustry(formData.industry || '')
const wizardSteps = getWizardSteps(formData.industry || '')
const totalSteps = wizardSteps.length
const lastStep = wizardSteps[wizardSteps.length - 1].id
// Load existing profile
useEffect(() => {
if (state.companyProfile) {
@@ -687,22 +1146,33 @@ export default function CompanyProfilePage() {
}
const handleNext = () => {
if (currentStep < 5) {
setCurrentStep(prev => prev + 1)
if (currentStep < lastStep) {
// Skip step 6 if not a machine builder
const nextStep = currentStep + 1
if (nextStep === 6 && !showMachineBuilderStep) {
// Complete profile (was step 5, last step for non-machine-builders)
completeAndSaveProfile()
return
}
setCurrentStep(nextStep)
} else {
// Complete profile
const completeProfile: CompanyProfile = {
...formData,
isComplete: true,
completedAt: new Date(),
} as CompanyProfile
setCompanyProfile(completeProfile)
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
goToNextStep()
completeAndSaveProfile()
}
}
const completeAndSaveProfile = () => {
const completeProfile: CompanyProfile = {
...formData,
isComplete: true,
completedAt: new Date(),
} as CompanyProfile
setCompanyProfile(completeProfile)
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
goToNextStep()
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(prev => prev - 1)
@@ -721,11 +1191,16 @@ export default function CompanyProfilePage() {
return formData.headquartersCountry && (formData.targetMarkets?.length || 0) > 0
case 5:
return true
case 6:
// Machine builder step: require at least product description
return (formData.machineBuilder?.productDescription?.length || 0) > 0
default:
return false
}
}
const isLastStep = currentStep === lastStep || (currentStep === 5 && !showMachineBuilderStep)
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
@@ -741,7 +1216,7 @@ export default function CompanyProfilePage() {
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
{WIZARD_STEPS.map((step, index) => (
{wizardSteps.map((step, index) => (
<React.Fragment key={step.id}>
<div className="flex items-center">
<div
@@ -775,7 +1250,7 @@ export default function CompanyProfilePage() {
</div>
</div>
</div>
{index < WIZARD_STEPS.length - 1 && (
{index < wizardSteps.length - 1 && (
<div
className={`flex-1 h-0.5 mx-4 ${
step.id < currentStep ? 'bg-purple-600' : 'bg-gray-200'
@@ -794,9 +1269,9 @@ export default function CompanyProfilePage() {
<div className="bg-white rounded-xl border border-gray-200 p-8">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900">
{WIZARD_STEPS[currentStep - 1].name}
{(wizardSteps.find(s => s.id === currentStep) || wizardSteps[0]).name}
</h2>
<p className="text-gray-500">{WIZARD_STEPS[currentStep - 1].description}</p>
<p className="text-gray-500">{(wizardSteps.find(s => s.id === currentStep) || wizardSteps[0]).description}</p>
</div>
{currentStep === 1 && <StepBasicInfo data={formData} onChange={updateFormData} />}
@@ -804,6 +1279,7 @@ export default function CompanyProfilePage() {
{currentStep === 3 && <StepCompanySize data={formData} onChange={updateFormData} />}
{currentStep === 4 && <StepLocations data={formData} onChange={updateFormData} />}
{currentStep === 5 && <StepDataProtection data={formData} onChange={updateFormData} />}
{currentStep === 6 && showMachineBuilderStep && <StepMachineBuilder data={formData} onChange={updateFormData} />}
{/* Navigation */}
<div className="flex justify-between mt-8 pt-6 border-t border-gray-200">
@@ -819,7 +1295,7 @@ export default function CompanyProfilePage() {
disabled={!canProceed()}
className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{currentStep === 5 ? 'Profil speichern & weiter' : 'Weiter'}
{isLastStep ? 'Profil speichern & weiter' : 'Weiter'}
</button>
</div>
</div>

View File

@@ -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 &quot;Klassifizieren&quot; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 &quot;Generieren&quot; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,147 @@
/**
* IACE (Industrial AI Compliance Engine) API Proxy - Catch-all route
* Proxies all /api/sdk/v1/iace/* requests to ai-compliance-sdk backend
* Supports PDF/ZIP export for CE Technical File
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/iace`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const userHeader = request.headers.get('x-user-id')
if (userHeader) {
headers['X-User-Id'] = userHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(60000), // 60s for LLM-based generation
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
} else if (contentType?.includes('multipart/form-data')) {
// Evidence upload: forward as-is
delete (headers as Record<string, string>)['Content-Type']
fetchOptions.body = await request.arrayBuffer()
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (PDF exports, ZIP CE technical file)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/zip') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('IACE API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -148,7 +148,7 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
{!collapsed && (
<Link href="/dashboard" className="font-bold text-lg">
Admin Compliance
Admin Lehrer KI
</Link>
)}
<button
@@ -194,10 +194,8 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
{/* Categories */}
<div className="px-2 space-y-1">
{visibleCategories.map((category) => {
const categoryHref = category.id === 'compliance-sdk' ? '/sdk' : `/${category.id}`
const isCategoryActive = category.id === 'compliance-sdk'
? category.modules.some(m => pathname.startsWith(m.href))
: pathname.startsWith(categoryHref)
const categoryHref = `/${category.id}`
const isCategoryActive = pathname.startsWith(categoryHref)
return (
<div key={category.id}>

View File

@@ -1,11 +1,11 @@
/**
* Navigation Structure for Admin Compliance
* Navigation Structure for Admin v2
*
* Compliance-only navigation with SDK modules.
* Extracted from admin-v2, keeping only compliance-relevant modules.
* Main categories with color-coded modules.
* All DSGVO and Compliance modules are now consolidated under the SDK.
*/
export type CategoryId = 'compliance-sdk' | 'development'
export type CategoryId = 'ai' | 'education' | 'website' | 'sdk-docs'
export interface NavModule {
id: string
@@ -15,8 +15,8 @@ export interface NavModule {
purpose: string
audience: string[]
gdprArticles?: string[]
oldAdminPath?: string
subgroup?: string
oldAdminPath?: string // Reference to old admin for migration
subgroup?: string // Optional subgroup for visual grouping in sidebar
}
export interface NavCategory {
@@ -31,89 +31,253 @@ export interface NavCategory {
export const navigation: NavCategory[] = [
// =========================================================================
// Compliance SDK - Alle Datenschutz-, Compliance- und SDK-Module
// KI & Automatisierung
// =========================================================================
{
id: 'compliance-sdk',
name: 'Compliance SDK',
icon: 'shield',
color: '#8b5cf6', // Violet-500
colorClass: 'compliance-sdk',
description: 'DSGVO, Audit, GRC & SDK-Werkzeuge',
id: 'ai',
name: 'KI & Automatisierung',
icon: 'brain',
color: '#14b8a6', // Teal
colorClass: 'ai',
description: 'LLM, OCR, RAG & Machine Learning',
modules: [
// -----------------------------------------------------------------------
// KI-Daten-Pipeline: Magic Help -> OCR -> Indexierung -> Suche
// -----------------------------------------------------------------------
{
id: 'catalog-manager',
name: 'Katalogverwaltung',
href: '/dashboard/catalog-manager',
description: 'SDK-Kataloge & Auswahltabellen',
purpose: 'Zentrale Verwaltung aller Dropdown- und Auswahltabellen im SDK. Systemkataloge (Risiken, Massnahmen, Vorlagen) anzeigen und benutzerdefinierte Eintraege ergaenzen, bearbeiten und loeschen.',
audience: ['DSB', 'Compliance Officer', 'Administratoren'],
},
// --- Plattform-Verwaltung (interne Admin-Tools) ---
{
id: 'multi-tenant',
name: 'Mandantenverwaltung',
href: '/dashboard/multi-tenant',
description: 'B2B-Kundenverwaltung & Mandanten',
purpose: 'Verwaltung aller Compliance-Mandanten (B2B-Kunden). Mandanten anlegen, konfigurieren, Lizenzen zuweisen und Nutzungsstatistiken einsehen.',
audience: ['Plattform-Admins', 'Entwickler'],
subgroup: 'Plattform-Verwaltung',
id: 'magic-help',
name: 'Magic Help (TrOCR)',
href: '/ai/magic-help',
description: 'TrOCR Testing & Fine-Tuning',
purpose: 'Testen und verbessern Sie die TrOCR-Handschrifterkennung. Laden Sie Bilder hoch, um die OCR-Qualitaet zu pruefen, und trainieren Sie das Modell mit LoRA Fine-Tuning. Bidirektionaler Austausch mit OCR-Labeling.',
audience: ['Entwickler', 'Administratoren', 'QA'],
oldAdminPath: '/admin/magic-help',
subgroup: 'KI-Daten-Pipeline',
},
{
id: 'sso',
name: 'SSO-Konfiguration',
href: '/dashboard/sso',
description: 'Single Sign-On & Authentifizierung',
purpose: 'Konfiguration der Authentifizierung fuer Mandanten. SAML/OIDC-Provider anbinden, SSO-Policies verwalten und Login-Flows testen.',
audience: ['Plattform-Admins', 'Entwickler'],
subgroup: 'Plattform-Verwaltung',
id: 'ocr-labeling',
name: 'OCR-Labeling',
href: '/ai/ocr-labeling',
description: 'Handschrift-Training & Labels',
purpose: 'Labeln Sie Handschrift-Samples fuer das Training von TrOCR-Modellen. Erstellen Sie Ground Truth Daten, die zur RAG Pipeline exportiert werden koennen.',
audience: ['Entwickler', 'Data Scientists', 'QA'],
oldAdminPath: '/admin/ocr-labeling',
subgroup: 'KI-Daten-Pipeline',
},
{
id: 'dsb-portal',
name: 'DSB Portal',
href: '/dashboard/dsb-portal',
description: 'Datenschutzbeauftragter-Arbeitsbereich',
purpose: 'Zentraler Arbeitsbereich fuer den externen Datenschutzbeauftragten (DSB). Aufgabenverwaltung, Beratungsprotokolle, Taetigkeitsbericht und mandantenuebergreifende Uebersicht gemaess Art. 37-39 DSGVO.',
audience: ['DSB', 'Plattform-Admins'],
gdprArticles: ['Art. 37', 'Art. 38', 'Art. 39'],
subgroup: 'Plattform-Verwaltung',
id: 'rag-pipeline',
name: 'RAG Pipeline',
href: '/ai/rag-pipeline',
description: 'Dokument-Indexierung',
purpose: 'RAG-Pipeline fuer Bildungsdokumente: NiBiS Erwartungshorizonte, Schulordnungen, Custom EH. OCR, Chunking und Vektor-Indexierung in Qdrant.',
audience: ['Entwickler', 'Data Scientists', 'Bildungs-Admins'],
oldAdminPath: '/admin/training',
subgroup: 'KI-Daten-Pipeline',
},
{
id: 'rag',
name: 'Daten & RAG',
href: '/ai/rag',
description: 'Vektor-Suche & Collections',
purpose: 'Verwalten und durchsuchen Sie indexierte Dokumente. Zeigt Status aller Qdrant Collections und ermoeglicht semantische Suche.',
audience: ['Entwickler', 'Data Scientists', 'Compliance Officer'],
oldAdminPath: '/admin/rag',
subgroup: 'KI-Daten-Pipeline',
},
// -----------------------------------------------------------------------
// KI-Werkzeuge: Standalone-Tools fuer Entwicklung & QA
// -----------------------------------------------------------------------
{
id: 'llm-compare',
name: 'LLM Vergleich',
href: '/ai/llm-compare',
description: 'KI-Provider Vergleich',
purpose: 'Vergleichen Sie verschiedene LLM-Anbieter (Ollama, OpenAI, Anthropic) hinsichtlich Qualitaet, Geschwindigkeit und Kosten. Standalone-Werkzeug fuer Modell-Evaluation.',
audience: ['Entwickler', 'Data Scientists'],
oldAdminPath: '/admin/llm-compare',
subgroup: 'KI-Werkzeuge',
},
{
id: 'ocr-compare',
name: 'OCR Vergleich',
href: '/ai/ocr-compare',
description: 'OCR-Methoden & Vokabel-Extraktion',
purpose: 'Vergleichen Sie verschiedene OCR-Methoden (lokales LLM, Vision LLM, PaddleOCR, Tesseract, Anthropic) fuer Vokabel-Extraktion. Grid-Overlay, Block-Review und LLM-Vergleich.',
audience: ['Entwickler', 'Data Scientists', 'Lehrer'],
subgroup: 'KI-Werkzeuge',
},
{
id: 'test-quality',
name: 'Test Quality (BQAS)',
href: '/ai/test-quality',
description: 'Golden Suite, RAG & Synthetic Tests',
purpose: 'BQAS Dashboard mit Golden Suite (97 Referenz-Tests), RAG/Korrektur Tests und Synthetic Test Generierung. Ueberwacht die Qualitaet der KI-Ausgaben.',
audience: ['Entwickler', 'Data Scientists', 'QA'],
oldAdminPath: '/admin/quality',
subgroup: 'KI-Werkzeuge',
},
{
id: 'gpu',
name: 'GPU Infrastruktur',
href: '/ai/gpu',
description: 'vast.ai GPU Management',
purpose: 'Verwalten Sie GPU-Instanzen auf vast.ai fuer ML-Training und Inferenz.',
audience: ['DevOps', 'Entwickler'],
oldAdminPath: '/admin/gpu',
subgroup: 'KI-Werkzeuge',
},
// -----------------------------------------------------------------------
// KI-Anwendungen: Endnutzer-orientierte KI-Module
// -----------------------------------------------------------------------
{
id: 'agents',
name: 'Agent Management',
href: '/ai/agents',
description: 'Multi-Agent System & SOUL-Editor',
purpose: 'Verwaltung des Multi-Agent-Systems. Bearbeiten Sie Agent-Persoenlichkeiten (SOUL-Files), ueberwachen Sie Sessions und analysieren Sie Agent-Statistiken. Architektur-Dokumentation fuer Entwickler.',
audience: ['Entwickler', 'Lehrer', 'Admins'],
subgroup: 'KI-Anwendungen',
},
],
},
// =========================================================================
// Development - Entwickler-Tools und Dokumentation
// Bildung & Schule
// =========================================================================
{
id: 'development',
name: 'Entwicklung & Produkte',
icon: 'code',
color: '#64748b', // Slate-500
colorClass: 'development',
description: 'Dokumentation, Screenflow & Brandbook',
id: 'education',
name: 'Bildung & Schule',
icon: 'graduation',
color: '#3b82f6', // Blue
colorClass: 'education',
description: 'Bildungsquellen & Lehrplaene',
modules: [
{
id: 'docs',
name: 'Developer Docs',
href: '/development/docs',
description: 'MkDocs Projekt-Dokumentation',
purpose: 'Technische Dokumentation der Compliance-Plattform mit Architektur, API-Referenz und Entwickler-Guides.',
audience: ['Entwickler', 'Architekten'],
id: 'edu-search',
name: 'Education Search',
href: '/education/edu-search',
description: 'Bildungsquellen & Crawler',
purpose: 'Verwalten Sie Bildungsquellen und konfigurieren Sie Crawler fuer neue Inhalte.',
audience: ['Content Manager'],
oldAdminPath: '/admin/edu-search',
},
{
id: 'screen-flow',
name: 'Screen Flow',
href: '/development/screen-flow',
description: 'UI Screen-Verbindungen & Navigation',
purpose: 'Visualisierung aller SDK-Screens und deren Verbindungen mit interaktivem ReactFlow-Diagramm.',
audience: ['Entwickler', 'Designer'],
id: 'zeugnisse-crawler',
name: 'Zeugnisse-Crawler',
href: '/education/zeugnisse-crawler',
description: 'Zeugnis-Daten',
purpose: 'Verwalten Sie gecrawlte Zeugnis-Strukturen und -Formate.',
audience: ['Entwickler'],
oldAdminPath: '/admin/zeugnisse-crawler',
},
{
id: 'brandbook',
name: 'Brandbook',
href: '/development/brandbook',
description: 'Corporate Design & Styleguide',
purpose: 'Compliance SDK Design-System mit Farben, Typografie, Komponenten und Tonalitaet.',
audience: ['Entwickler', 'Designer'],
id: 'abitur-archiv',
name: 'Abitur-Archiv',
href: '/education/abitur-archiv',
description: 'Zentralabitur-Materialien 2021-2025',
purpose: 'Durchsuchen und filtern Sie Abitur-Aufgaben und Erwartungshorizonte. Themensuche mit semantischer Suche via RAG. Integration mit Klausur-Korrektur fuer schnelle Vorlagen-Nutzung.',
audience: ['Lehrer', 'Entwickler'],
},
{
id: 'klausur-korrektur',
name: 'Klausur-Korrektur',
href: '/education/klausur-korrektur',
description: 'Abitur-Korrektur mit KI',
purpose: 'KI-gestuetzte Korrektur von Abitur- und Vorabitur-Klausuren. Nutzt die RAG-Pipeline fuer Erwartungshorizont-Vorschlaege.',
audience: ['Lehrer', 'Entwickler'],
oldAdminPath: '/admin/klausur-korrektur',
},
],
},
// =========================================================================
// Website
// =========================================================================
{
id: 'website',
name: 'Website',
icon: 'globe',
color: '#0ea5e9', // Sky-500
colorClass: 'website',
description: 'Website Content & Management',
modules: [
{
id: 'uebersetzungen',
name: 'Uebersetzungen',
href: '/website/uebersetzungen',
description: 'Website Content & Sprachen',
purpose: 'Verwalten Sie Website-Inhalte und Uebersetzungen.',
audience: ['Content Manager'],
oldAdminPath: '/admin/content',
},
{
id: 'manager',
name: 'Website Manager',
href: '/website/manager',
description: 'CMS Dashboard',
purpose: 'Visuelles CMS-Dashboard fuer die BreakPilot Website. Alle Sektionen bearbeiten mit Live-Preview.',
audience: ['Content Manager', 'Entwickler'],
},
],
},
// =========================================================================
// SDK Dokumentation
// =========================================================================
{
id: 'sdk-docs',
name: 'SDK Dokumentation',
icon: 'code-2',
color: '#06b6d4', // Cyan
colorClass: 'sdk-docs',
description: 'Consent SDK Dokumentation & Integration',
modules: [
{
id: 'consent-sdk',
name: 'Consent SDK',
href: '/developers/sdk/consent',
description: 'DSGVO/TTDSG-konformes Consent Management',
purpose: 'Vollstaendige Dokumentation des Consent SDK fuer Web, PWA und Mobile Apps. Inklusive Framework-Integrationen (React, Vue, Angular) und Mobile SDKs (iOS, Android, Flutter).',
audience: ['Entwickler', 'Frontend-Entwickler', 'Mobile-Entwickler'],
gdprArticles: ['Art. 6', 'Art. 7', 'Art. 13', 'Art. 14', 'Art. 17', 'Art. 20'],
},
{
id: 'sdk-installation',
name: 'Installation',
href: '/developers/sdk/consent/installation',
description: 'SDK Installation & Setup',
purpose: 'Schritt-fuer-Schritt Anleitung zur Installation des Consent SDK in verschiedenen Umgebungen.',
audience: ['Entwickler'],
},
{
id: 'sdk-frameworks',
name: 'Frameworks',
href: '/developers/sdk/consent/frameworks',
description: 'React, Vue, Angular Integration',
purpose: 'Framework-spezifische Integrationen mit Hooks, Composables und Services.',
audience: ['Frontend-Entwickler'],
},
{
id: 'sdk-mobile',
name: 'Mobile SDKs',
href: '/developers/sdk/consent/mobile',
description: 'iOS, Android, Flutter',
purpose: 'Native Mobile SDKs fuer iOS (Swift), Android (Kotlin) und Flutter (Dart).',
audience: ['Mobile-Entwickler'],
},
{
id: 'sdk-api',
name: 'API Referenz',
href: '/developers/sdk/consent/api-reference',
description: 'Vollstaendige API-Dokumentation',
purpose: 'Detaillierte Dokumentation aller Methoden, Konfigurationsoptionen und Events.',
audience: ['Entwickler'],
},
{
id: 'sdk-security',
name: 'Sicherheit',
href: '/developers/sdk/consent/security',
description: 'Security Best Practices',
purpose: 'Sicherheits-Features, DSGVO/TTDSG Compliance-Hinweise und Best Practices.',
audience: ['Entwickler', 'DSB', 'Security'],
gdprArticles: ['Art. 6', 'Art. 7', '§ 25 TTDSG'],
},
],
},
@@ -130,6 +294,41 @@ export const metaModules: NavModule[] = [
audience: ['Alle'],
oldAdminPath: '/admin',
},
{
id: 'architecture',
name: 'Architektur',
href: '/architecture',
description: 'Backend-Module & Datenfluss',
purpose: 'Uebersicht aller Backend-Module und deren Verbindung zum Frontend. Essentiell fuer Migration und Audit.',
audience: ['Entwickler', 'DevOps', 'Auditoren', 'Manager'],
},
{
id: 'onboarding',
name: 'Onboarding',
href: '/onboarding',
description: 'Lern-Wizards',
purpose: 'Gefuehrte Tutorials fuer neue Benutzer.',
audience: ['Alle'],
oldAdminPath: '/admin/onboarding',
},
{
id: 'backlog',
name: 'Production Backlog',
href: '/backlog',
description: 'Go-Live Checkliste',
purpose: 'Verfolgen Sie den Fortschritt zum Production-Launch.',
audience: ['Entwickler', 'Manager'],
oldAdminPath: '/admin/backlog',
},
{
id: 'rbac',
name: 'RBAC',
href: '/rbac',
description: 'Rollen & Berechtigungen',
purpose: 'Verwalten Sie Benutzerrollen und Zugriffsrechte.',
audience: ['Admins', 'DSB'],
oldAdminPath: '/admin/rbac',
},
]
// Helper function to get category by ID

View File

@@ -1,8 +1,7 @@
/**
* Role-based Access System for Admin Compliance
* Role-based Access System for Admin v2
*
* Roles determine which categories and modules are visible.
* Extracted from admin-v2, keeping only SDK/compliance roles.
* Roles determine which categories and modules are visible
*/
import { CategoryId } from './navigation'
@@ -22,9 +21,9 @@ export const roles: Role[] = [
{
id: 'developer',
name: 'Entwickler',
description: 'Voller Zugriff auf alle Compliance-Bereiche',
description: 'Voller Zugriff auf alle Bereiche',
icon: 'code',
visibleCategories: ['compliance-sdk', 'development'],
visibleCategories: ['ai', 'education', 'website'],
color: 'bg-primary-100 border-primary-300 text-primary-700',
},
{
@@ -32,7 +31,7 @@ export const roles: Role[] = [
name: 'Manager',
description: 'Executive Uebersicht',
icon: 'chart',
visibleCategories: ['compliance-sdk', 'development'],
visibleCategories: ['website'],
color: 'bg-blue-100 border-blue-300 text-blue-700',
},
{
@@ -40,7 +39,7 @@ export const roles: Role[] = [
name: 'Auditor',
description: 'Compliance Pruefung',
icon: 'clipboard',
visibleCategories: ['compliance-sdk', 'development'],
visibleCategories: [],
color: 'bg-amber-100 border-amber-300 text-amber-700',
},
{
@@ -48,13 +47,13 @@ export const roles: Role[] = [
name: 'DSB',
description: 'Datenschutzbeauftragter',
icon: 'shield',
visibleCategories: ['compliance-sdk', 'development'],
visibleCategories: [],
color: 'bg-purple-100 border-purple-300 text-purple-700',
},
]
// Storage key for localStorage
const ROLE_STORAGE_KEY = 'admin-compliance-selected-role'
const ROLE_STORAGE_KEY = 'admin-v2-selected-role'
// Get role by ID
export function getRoleById(id: RoleId): Role | undefined {

View File

@@ -13,6 +13,7 @@ import type {
ScopeDocumentType,
DocumentScopeRequirement,
} from './compliance-scope-types'
import type { CompanyProfile, MachineBuilderProfile } from './types'
import {
getDepthLevelNumeric,
depthLevelFromNumeric,
@@ -786,6 +787,176 @@ export const HARD_TRIGGER_RULES: HardTriggerRule[] = [
legalReference: 'Art. 39 Abs. 1 lit. b DSGVO',
description: 'Fehlende Schulungen zum Datenschutz',
},
// ========== J: IACE — AI Act Produkt-Triggers (3 rules) ==========
{
id: 'HT-J01',
category: 'iace_ai_act_product',
questionId: 'machineBuilder.containsAI',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L3',
requiresDSFA: false,
mandatoryDocuments: ['VVT', 'TOM'],
legalReference: 'EU AI Act Annex I + EU Maschinenverordnung 2023/1230',
description: 'KI mit Sicherheitsfunktion in Maschine → AI Act High-Risk',
combineWithMachineBuilder: { field: 'hasSafetyFunction', value: true },
riskWeight: 9,
},
{
id: 'HT-J02',
category: 'iace_ai_act_product',
questionId: 'machineBuilder.containsAI',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L3',
requiresDSFA: false,
mandatoryDocuments: ['VVT', 'TOM'],
legalReference: 'EU AI Act + EU Maschinenverordnung 2023/1230',
description: 'Autonome KI in Maschine → AI Act + Maschinenverordnung',
combineWithMachineBuilder: { field: 'autonomousBehavior', value: true },
riskWeight: 8,
},
{
id: 'HT-J03',
category: 'iace_ai_act_product',
questionId: 'machineBuilder.hasSafetyFunction',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L3',
requiresDSFA: false,
mandatoryDocuments: ['VVT', 'TOM'],
legalReference: 'EU AI Act Annex III',
description: 'KI-Bildverarbeitung mit Sicherheitsbezug',
combineWithMachineBuilder: { field: 'aiIntegrationType', includes: 'vision' },
riskWeight: 8,
},
// ========== K: IACE — CRA Triggers (3 rules) ==========
{
id: 'HT-K01',
category: 'iace_cra',
questionId: 'machineBuilder.isNetworked',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L2',
requiresDSFA: false,
mandatoryDocuments: ['TOM'],
legalReference: 'EU Cyber Resilience Act (CRA)',
description: 'Vernetztes Produkt → Cyber Resilience Act',
riskWeight: 6,
},
{
id: 'HT-K02',
category: 'iace_cra',
questionId: 'machineBuilder.hasRemoteAccess',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L2',
requiresDSFA: false,
mandatoryDocuments: ['TOM'],
legalReference: 'CRA + NIS2 Art. 21',
description: 'Remote-Zugriff → CRA + NIS2 Supply Chain',
riskWeight: 7,
},
{
id: 'HT-K03',
category: 'iace_cra',
questionId: 'machineBuilder.hasOTAUpdates',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L2',
requiresDSFA: false,
mandatoryDocuments: ['TOM'],
legalReference: 'CRA Art. 10 - Patch Management',
description: 'OTA-Updates → CRA Patch Management Pflicht',
riskWeight: 7,
},
// ========== L: IACE — NIS2 indirekt (2 rules) ==========
{
id: 'HT-L01',
category: 'iace_nis2_indirect',
questionId: 'machineBuilder.criticalSectorClients',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L2',
requiresDSFA: false,
mandatoryDocuments: ['TOM'],
legalReference: 'NIS2 Art. 21 - Supply Chain',
description: 'Lieferant an KRITIS → NIS2 Supply Chain Anforderungen',
riskWeight: 7,
},
{
id: 'HT-L02',
category: 'iace_nis2_indirect',
questionId: 'machineBuilder.oemClients',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L2',
requiresDSFA: false,
mandatoryDocuments: [],
legalReference: 'NIS2 + EU Maschinenverordnung',
description: 'OEM-Zulieferer → Compliance-Nachweispflicht',
riskWeight: 5,
},
// ========== M: IACE — Maschinenverordnung Triggers (4 rules) ==========
{
id: 'HT-M01',
category: 'iace_machinery_regulation',
questionId: 'machineBuilder.containsSoftware',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L3',
requiresDSFA: false,
mandatoryDocuments: ['TOM'],
legalReference: 'EU Maschinenverordnung 2023/1230 Anhang III',
description: 'Software als Sicherheitskomponente → Maschinenverordnung',
combineWithMachineBuilder: { field: 'hasSafetyFunction', value: true },
riskWeight: 9,
},
{
id: 'HT-M02',
category: 'iace_machinery_regulation',
questionId: 'machineBuilder.ceMarkingRequired',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L2',
requiresDSFA: false,
mandatoryDocuments: [],
legalReference: 'EU Maschinenverordnung 2023/1230',
description: 'CE-Kennzeichnung erforderlich',
riskWeight: 6,
},
{
id: 'HT-M03',
category: 'iace_machinery_regulation',
questionId: 'machineBuilder.ceMarkingRequired',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L3',
requiresDSFA: false,
mandatoryDocuments: [],
legalReference: 'EU Maschinenverordnung 2023/1230 Art. 10',
description: 'CE ohne bestehende Risikobeurteilung → Dringend!',
combineWithMachineBuilder: { field: 'hasRiskAssessment', value: false },
riskWeight: 9,
},
{
id: 'HT-M04',
category: 'iace_machinery_regulation',
questionId: 'machineBuilder.containsFirmware',
condition: 'EQUALS',
conditionValue: true,
minimumLevel: 'L2',
requiresDSFA: false,
mandatoryDocuments: ['TOM'],
legalReference: 'EU Maschinenverordnung + CRA',
description: 'Firmware mit Remote-Update → Change Management Pflicht',
combineWithMachineBuilder: { field: 'hasOTAUpdates', value: true },
riskWeight: 7,
},
]
// ============================================================================
@@ -795,15 +966,16 @@ export const HARD_TRIGGER_RULES: HardTriggerRule[] = [
export class ComplianceScopeEngine {
/**
* Haupteinstiegspunkt: Evaluiert alle Profiling-Antworten und produziert eine ScopeDecision
* Optional: companyProfile fuer machineBuilder-basierte IACE Triggers
*/
evaluate(answers: ScopeProfilingAnswer[]): ScopeDecision {
evaluate(answers: ScopeProfilingAnswer[], companyProfile?: CompanyProfile | null): ScopeDecision {
const decision = createEmptyScopeDecision()
// 1. Scores berechnen
decision.scores = this.calculateScores(answers)
// 2. Hard Triggers prüfen
decision.triggeredHardTriggers = this.evaluateHardTriggers(answers)
// 2. Hard Triggers prüfen (inkl. IACE machineBuilder Triggers)
decision.triggeredHardTriggers = this.evaluateHardTriggers(answers, companyProfile)
// 3. Finales Level bestimmen
decision.determinedLevel = this.determineLevel(
@@ -934,13 +1106,14 @@ export class ComplianceScopeEngine {
/**
* Evaluiert Hard Trigger Rules
* Optional: companyProfile fuer machineBuilder-basierte IACE Triggers
*/
evaluateHardTriggers(answers: ScopeProfilingAnswer[]): TriggeredHardTrigger[] {
evaluateHardTriggers(answers: ScopeProfilingAnswer[], companyProfile?: CompanyProfile | null): TriggeredHardTrigger[] {
const triggered: TriggeredHardTrigger[] = []
const answerMap = new Map(answers.map((a) => [a.questionId, a.answerValue]))
for (const rule of HARD_TRIGGER_RULES) {
const isTriggered = this.checkTriggerCondition(rule, answerMap, answers)
const isTriggered = this.checkTriggerCondition(rule, answerMap, answers, companyProfile)
if (isTriggered) {
triggered.push({
@@ -958,14 +1131,61 @@ export class ComplianceScopeEngine {
return triggered
}
/**
* Liest einen Wert aus dem MachineBuilderProfile anhand eines Feldnamens
*/
private getMachineBuilderValue(mb: MachineBuilderProfile, field: string): unknown {
return (mb as Record<string, unknown>)[field]
}
/**
* Prüft, ob eine Trigger-Regel erfüllt ist
*/
private checkTriggerCondition(
rule: HardTriggerRule,
answerMap: Map<string, any>,
answers: ScopeProfilingAnswer[]
answers: ScopeProfilingAnswer[],
companyProfile?: CompanyProfile | null,
): boolean {
// IACE machineBuilder-basierte Triggers
if (rule.questionId.startsWith('machineBuilder.')) {
const mb = companyProfile?.machineBuilder
if (!mb) return false
const fieldName = rule.questionId.replace('machineBuilder.', '')
const fieldValue = this.getMachineBuilderValue(mb, fieldName)
if (fieldValue === undefined) return false
let baseCondition = false
switch (rule.condition) {
case 'EQUALS':
baseCondition = fieldValue === rule.conditionValue
break
case 'CONTAINS':
if (Array.isArray(fieldValue)) {
baseCondition = fieldValue.includes(rule.conditionValue)
}
break
default:
baseCondition = fieldValue === rule.conditionValue
}
if (!baseCondition) return false
// combineWithMachineBuilder: additional AND condition on another MB field
const combine = (rule as any).combineWithMachineBuilder
if (combine) {
const combineVal = this.getMachineBuilderValue(mb, combine.field)
if (combine.value !== undefined && combineVal !== combine.value) return false
if (combine.includes !== undefined) {
if (!Array.isArray(combineVal) || !combineVal.includes(combine.includes)) return false
}
}
return true
}
// Standard answer-based triggers
const answerValue = answerMap.get(rule.questionId)
if (answerValue === undefined) return false

View File

@@ -183,7 +183,8 @@ export type ScopeDocumentType =
| 'risikoanalyse' // Risikoanalyse
| 'notfallplan' // Notfall- & Krisenplan
| 'zertifizierung' // Zertifizierungsvorbereitung
| 'datenschutzmanagement'; // Datenschutzmanagement-System (DSMS)
| 'datenschutzmanagement' // Datenschutzmanagement-System (DSMS)
| 'iace_ce_assessment'; // CE-Risikobeurteilung SW/FW/KI (IACE)
// ============================================================================
// Decision & Output Types
@@ -412,6 +413,7 @@ export const DOCUMENT_TYPE_LABELS: Record<ScopeDocumentType, string> = {
notfallplan: 'Notfall- & Krisenplan',
zertifizierung: 'Zertifizierungsvorbereitung',
datenschutzmanagement: 'Datenschutzmanagement-System (DSMS)',
iace_ce_assessment: 'CE-Risikobeurteilung SW/FW/KI (IACE)',
};
/**
@@ -1246,6 +1248,54 @@ export const DOCUMENT_SCOPE_MATRIX: Record<ScopeDocumentType, DocumentScopeRequi
estimatedEffort: '24-40 Stunden',
},
},
iace_ce_assessment: {
L1: {
required: false,
depth: 'Minimal',
detailItems: [
'Regulatorischer Quick-Check fuer SW/FW/KI',
'Grundlegende Identifikation relevanter Vorschriften',
],
estimatedEffort: '2 Stunden',
},
L2: {
required: true,
depth: 'Standard',
detailItems: [
'CE-Risikobeurteilung fuer SW/FW-Komponenten',
'Hazard Log mit S×E×P Bewertung',
'CRA-Konformitaetspruefung',
'Grundlegende Massnahmendokumentation',
],
estimatedEffort: '8 Stunden',
},
L3: {
required: true,
depth: 'Detailliert',
detailItems: [
'Alle L2-Anforderungen',
'Vollstaendige CE-Akte inkl. KI-Dossier',
'AI Act High-Risk Konformitaetsbewertung',
'Maschinenverordnung Anhang III Nachweis',
'Verifikationsplan mit Akzeptanzkriterien',
'Evidence-Management fuer Testnachweise',
],
estimatedEffort: '16 Stunden',
},
L4: {
required: true,
depth: 'Audit-Ready',
detailItems: [
'Alle L3-Anforderungen',
'Zertifizierungsfertige CE-Dokumentation',
'Benannte-Stelle-tauglicher Nachweis',
'Revisionssichere Audit Trails',
'Post-Market Monitoring Plan',
'Continuous Compliance Framework',
],
estimatedEffort: '24 Stunden',
},
},
};
// ============================================================================
@@ -1273,6 +1323,7 @@ export const DOCUMENT_SDK_STEP_MAP: Partial<Record<ScopeDocumentType, string>> =
notfallplan: '/sdk/notfallplan',
zertifizierung: '/sdk/zertifizierung',
datenschutzmanagement: '/sdk/dsms',
iace_ce_assessment: '/sdk/iace',
};
// ============================================================================