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