feat: DSFA Section 8 KI-Anwendungsfälle + Bundesland RAG-Ingest
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 38s
CI / test-python-backend-compliance (push) Successful in 33s
CI / test-python-document-crawler (push) Successful in 24s
CI / test-python-dsms-gateway (push) Successful in 19s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 38s
CI / test-python-backend-compliance (push) Successful in 33s
CI / test-python-document-crawler (push) Successful in 24s
CI / test-python-dsms-gateway (push) Successful in 19s
- Migration 028: ai_use_case_modules JSONB + section_8_complete auf compliance_dsfas - Neues ai-use-case-types.ts: AIUseCaseModule Interface, 8 Typen, Art22Assessment, AI Act Risikoklassen, WP248-Kriterien, Privacy by Design, createEmptyModule() Helper - types.ts: Section 8 in DSFA_SECTIONS, ai_use_case_modules im DSFA Interface, section_8_complete in DSFASectionProgress - api.ts: addAIUseCaseModule, updateAIUseCaseModule, removeAIUseCaseModule - 5 neue UI-Komponenten: AIUseCaseTypeSelector, Art22AssessmentPanel, AIRiskCriteriaChecklist, AIUseCaseModuleEditor (7 Tabs), AIUseCaseSection - DSFASidebar: Section 8 Eintrag + calculateSectionProgress case 8 - ReviewScheduleSection: ai_use_case_module Trigger-Typ ergänzt - page.tsx: Section 8 Rendering + Weiter-Button auf activeSection < 8 + KI-Module Counter - scripts/ingest-dsfa-bundesland.sh: WP248 + alle 17 Behörden → bp_dsfa_corpus - Docs: dsfa.md Section 8 + RAG-Corpus, Developer Portal DSFA mit AI-Modul-Code-Beispielen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
129
admin-compliance/components/sdk/dsfa/AIRiskCriteriaChecklist.tsx
Normal file
129
admin-compliance/components/sdk/dsfa/AIRiskCriteriaChecklist.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AI_RISK_CRITERIA, AIUseCaseRiskCriterion } from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
|
||||
interface AIRiskCriteriaChecklistProps {
|
||||
criteria: AIUseCaseRiskCriterion[]
|
||||
onChange: (updated: AIUseCaseRiskCriterion[]) => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
low: 'Niedrig',
|
||||
medium: 'Mittel',
|
||||
high: 'Hoch',
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
low: 'bg-green-100 text-green-700 border-green-200',
|
||||
medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
high: 'bg-red-100 text-red-700 border-red-200',
|
||||
}
|
||||
|
||||
export function AIRiskCriteriaChecklist({ criteria, onChange, readonly }: AIRiskCriteriaChecklistProps) {
|
||||
const appliedCount = criteria.filter(c => c.applies).length
|
||||
|
||||
const updateCriterion = (id: string, updates: Partial<AIUseCaseRiskCriterion>) => {
|
||||
onChange(criteria.map(c => c.id === id ? { ...c, ...updates } : c))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Summary Banner */}
|
||||
{appliedCount > 0 && (
|
||||
<div className={`p-3 rounded-lg border text-sm ${
|
||||
appliedCount >= 3
|
||||
? 'bg-red-50 border-red-200 text-red-700'
|
||||
: appliedCount >= 2
|
||||
? 'bg-orange-50 border-orange-200 text-orange-700'
|
||||
: 'bg-yellow-50 border-yellow-200 text-yellow-700'
|
||||
}`}>
|
||||
{appliedCount} von 6 Risikokriterien erfüllt
|
||||
{appliedCount >= 2 && ' – DSFA ist erforderlich'}
|
||||
{appliedCount >= 4 && ' – behördliche Konsultation prüfen'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Criteria Cards */}
|
||||
<div className="space-y-2">
|
||||
{AI_RISK_CRITERIA.map(critDef => {
|
||||
const criterion = criteria.find(c => c.id === critDef.id) || {
|
||||
id: critDef.id,
|
||||
applies: false,
|
||||
severity: critDef.default_severity,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={critDef.id}
|
||||
className={`rounded-xl border p-4 transition-all ${
|
||||
criterion.applies
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={criterion.applies}
|
||||
onChange={e => updateCriterion(critDef.id, { applies: e.target.checked })}
|
||||
disabled={readonly}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-medium text-sm ${criterion.applies ? 'text-red-800' : 'text-gray-900'}`}>
|
||||
{critDef.label}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${SEVERITY_COLORS[criterion.severity]}`}>
|
||||
{SEVERITY_LABELS[criterion.severity]}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded font-mono">
|
||||
{critDef.gdpr_ref.split(',')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{critDef.description}</p>
|
||||
|
||||
{/* Justification (shown when applies) */}
|
||||
{criterion.applies && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<textarea
|
||||
value={criterion.justification || ''}
|
||||
onChange={e => updateCriterion(critDef.id, { justification: e.target.value })}
|
||||
disabled={readonly}
|
||||
rows={2}
|
||||
placeholder="Begründung, warum dieses Kriterium zutrifft..."
|
||||
className="w-full px-3 py-2 text-xs border border-red-200 rounded-lg bg-white focus:ring-2 focus:ring-red-400 focus:border-red-400 resize-none"
|
||||
/>
|
||||
|
||||
{/* Severity Override */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Schwere:</span>
|
||||
{(['low', 'medium', 'high'] as const).map(sev => (
|
||||
<button
|
||||
key={sev}
|
||||
onClick={() => !readonly && updateCriterion(critDef.id, { severity: sev })}
|
||||
className={`text-xs px-2 py-0.5 rounded border transition-colors ${
|
||||
criterion.severity === sev
|
||||
? SEVERITY_COLORS[sev]
|
||||
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{SEVERITY_LABELS[sev]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
698
admin-compliance/components/sdk/dsfa/AIUseCaseModuleEditor.tsx
Normal file
698
admin-compliance/components/sdk/dsfa/AIUseCaseModuleEditor.tsx
Normal file
@@ -0,0 +1,698 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
AIUseCaseModule,
|
||||
AIUseCaseType,
|
||||
AIActRiskClass,
|
||||
AI_USE_CASE_TYPES,
|
||||
AI_ACT_RISK_CLASSES,
|
||||
PRIVACY_BY_DESIGN_CATEGORIES,
|
||||
PrivacyByDesignCategory,
|
||||
PrivacyByDesignMeasure,
|
||||
AIModuleReviewTrigger,
|
||||
AIModuleReviewTriggerType,
|
||||
checkArt22Applicability,
|
||||
} from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
import { Art22AssessmentPanel } from './Art22AssessmentPanel'
|
||||
import { AIRiskCriteriaChecklist } from './AIRiskCriteriaChecklist'
|
||||
|
||||
interface AIUseCaseModuleEditorProps {
|
||||
module: AIUseCaseModule
|
||||
onSave: (module: AIUseCaseModule) => void
|
||||
onCancel: () => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 1, label: 'System', icon: '🖥️' },
|
||||
{ id: 2, label: 'Daten', icon: '📊' },
|
||||
{ id: 3, label: 'Zweck & Art. 22', icon: '⚖️' },
|
||||
{ id: 4, label: 'KI-Kriterien', icon: '🔍' },
|
||||
{ id: 5, label: 'Risiken', icon: '⚠️' },
|
||||
{ id: 6, label: 'Maßnahmen', icon: '🛡️' },
|
||||
{ id: 7, label: 'Review', icon: '🔄' },
|
||||
]
|
||||
|
||||
const REVIEW_TRIGGER_TYPES: { value: AIModuleReviewTriggerType; label: string; icon: string }[] = [
|
||||
{ value: 'model_update', label: 'Modell-Update', icon: '🔄' },
|
||||
{ value: 'data_drift', label: 'Datendrift', icon: '📉' },
|
||||
{ value: 'accuracy_drop', label: 'Genauigkeitsabfall', icon: '📊' },
|
||||
{ value: 'new_use_case', label: 'Neuer Anwendungsfall', icon: '🎯' },
|
||||
{ value: 'regulatory_change', label: 'Regulatorische Änderung', icon: '📜' },
|
||||
{ value: 'incident', label: 'Sicherheitsvorfall', icon: '🚨' },
|
||||
{ value: 'periodic', label: 'Regelmäßig (zeitbasiert)', icon: '📅' },
|
||||
]
|
||||
|
||||
const LEGAL_BASES = [
|
||||
'Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)',
|
||||
'Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)',
|
||||
'Art. 6 Abs. 1 lit. c DSGVO (Rechtliche Verpflichtung)',
|
||||
'Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse)',
|
||||
'Art. 9 Abs. 2 lit. a DSGVO (Ausdrückliche Einwilligung)',
|
||||
]
|
||||
|
||||
export function AIUseCaseModuleEditor({ module: initialModule, onSave, onCancel, isSaving }: AIUseCaseModuleEditorProps) {
|
||||
const [activeTab, setActiveTab] = useState(1)
|
||||
const [module, setModule] = useState<AIUseCaseModule>(initialModule)
|
||||
const [newCategory, setNewCategory] = useState('')
|
||||
const [newOutputCategory, setNewOutputCategory] = useState('')
|
||||
const [newSubject, setNewSubject] = useState('')
|
||||
|
||||
const typeInfo = AI_USE_CASE_TYPES[module.use_case_type]
|
||||
const art22Required = checkArt22Applicability(module)
|
||||
|
||||
const update = (updates: Partial<AIUseCaseModule>) => {
|
||||
setModule(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
const addToList = (field: keyof AIUseCaseModule, value: string, setter: (v: string) => void) => {
|
||||
if (!value.trim()) return
|
||||
const current = (module[field] as string[]) || []
|
||||
update({ [field]: [...current, value.trim()] } as Partial<AIUseCaseModule>)
|
||||
setter('')
|
||||
}
|
||||
|
||||
const removeFromList = (field: keyof AIUseCaseModule, idx: number) => {
|
||||
const current = (module[field] as string[]) || []
|
||||
update({ [field]: current.filter((_, i) => i !== idx) } as Partial<AIUseCaseModule>)
|
||||
}
|
||||
|
||||
const togglePbdMeasure = (category: PrivacyByDesignCategory) => {
|
||||
const existing = module.privacy_by_design_measures || []
|
||||
const found = existing.find(m => m.category === category)
|
||||
if (found) {
|
||||
update({ privacy_by_design_measures: existing.map(m =>
|
||||
m.category === category ? { ...m, implemented: !m.implemented } : m
|
||||
)})
|
||||
} else {
|
||||
const newMeasure: PrivacyByDesignMeasure = {
|
||||
category,
|
||||
description: PRIVACY_BY_DESIGN_CATEGORIES[category].description,
|
||||
implemented: true,
|
||||
}
|
||||
update({ privacy_by_design_measures: [...existing, newMeasure] })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleReviewTrigger = (type: AIModuleReviewTriggerType) => {
|
||||
const existing = module.review_triggers || []
|
||||
const found = existing.find(t => t.type === type)
|
||||
if (found) {
|
||||
update({ review_triggers: existing.filter(t => t.type !== type) })
|
||||
} else {
|
||||
const label = REVIEW_TRIGGER_TYPES.find(rt => rt.value === type)?.label || type
|
||||
const newTrigger: AIModuleReviewTrigger = { type, description: label }
|
||||
update({ review_triggers: [...existing, newTrigger] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{typeInfo.icon}</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{module.name || typeInfo.label}</h2>
|
||||
<p className="text-xs text-gray-500">{typeInfo.label} — KI-Anwendungsfall-Anhang</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Bar */}
|
||||
<div className="flex gap-1 px-4 py-2 border-b border-gray-200 overflow-x-auto flex-shrink-0">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Tab 1: Systembeschreibung */}
|
||||
{activeTab === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Anwendungsfalls *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.name}
|
||||
onChange={e => update({ name: e.target.value })}
|
||||
placeholder={`z.B. ${typeInfo.label} für Kundenservice`}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung *</label>
|
||||
<textarea
|
||||
value={module.model_description}
|
||||
onChange={e => update({ model_description: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie das KI-System: Funktionsweise, Input/Output, eingesetzte Algorithmen..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Modell-Typ</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.model_type || ''}
|
||||
onChange={e => update({ model_type: e.target.value })}
|
||||
placeholder="z.B. Random Forest, GPT-4, CNN"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anbieter / Provider</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.provider || ''}
|
||||
onChange={e => update({ provider: e.target.value })}
|
||||
placeholder="z.B. Anthropic, OpenAI, intern"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenfluss-Beschreibung</label>
|
||||
<textarea
|
||||
value={module.data_flow_description || ''}
|
||||
onChange={e => update({ data_flow_description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Wie fließen Daten in das KI-System ein und aus? Gibt es Drittland-Transfers?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="third_country"
|
||||
checked={module.third_country_transfer}
|
||||
onChange={e => update({ third_country_transfer: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<label htmlFor="third_country" className="text-sm text-gray-700">
|
||||
Drittland-Transfer (außerhalb EU/EWR)
|
||||
</label>
|
||||
{module.third_country_transfer && (
|
||||
<input
|
||||
type="text"
|
||||
value={module.provider_country || ''}
|
||||
onChange={e => update({ provider_country: e.target.value })}
|
||||
placeholder="Land (z.B. USA)"
|
||||
className="ml-2 px-2 py-1 text-sm border border-orange-300 rounded focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Daten & Betroffene */}
|
||||
{activeTab === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* Input Data Categories */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Input-Datenkategorien *</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategory}
|
||||
onChange={e => setNewCategory(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('input_data_categories', newCategory, setNewCategory)}
|
||||
placeholder="Datenkategorie hinzufügen..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('input_data_categories', newCategory, setNewCategory)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.input_data_categories || []).map((cat, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
{cat}
|
||||
<button onClick={() => removeFromList('input_data_categories', i)} className="hover:text-purple-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Data Categories */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Output-Datenkategorien</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOutputCategory}
|
||||
onChange={e => setNewOutputCategory(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
|
||||
placeholder="Output-Kategorie (z.B. Bewertung, Empfehlung)..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.output_data_categories || []).map((cat, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{cat}
|
||||
<button onClick={() => removeFromList('output_data_categories', i)} className="hover:text-blue-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Special Categories */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="special_cats"
|
||||
checked={module.involves_special_categories}
|
||||
onChange={e => update({ involves_special_categories: e.target.checked })}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<label htmlFor="special_cats" className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Besondere Kategorien (Art. 9 DSGVO)</div>
|
||||
<p className="text-xs text-gray-500">Gesundheit, Biometrie, Religion, politische Meinung etc.</p>
|
||||
{module.involves_special_categories && (
|
||||
<textarea
|
||||
value={module.special_categories_justification || ''}
|
||||
onChange={e => update({ special_categories_justification: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Begründung nach Art. 9 Abs. 2 DSGVO..."
|
||||
className="mt-2 w-full px-3 py-2 text-xs border border-orange-300 rounded focus:ring-2 focus:ring-orange-400 resize-none"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Data Subjects */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betroffenengruppen *</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubject}
|
||||
onChange={e => setNewSubject(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('data_subjects', newSubject, setNewSubject)}
|
||||
placeholder="z.B. Kunden, Mitarbeiter, Nutzer..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('data_subjects', newSubject, setNewSubject)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.data_subjects || []).map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
|
||||
{s}
|
||||
<button onClick={() => removeFromList('data_subjects', i)} className="hover:text-green-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschätztes Volumen</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.estimated_volume || ''}
|
||||
onChange={e => update({ estimated_volume: e.target.value })}
|
||||
placeholder="z.B. >10.000 Personen/Monat"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungsdauer (Monate)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={module.data_retention_months || ''}
|
||||
onChange={e => update({ data_retention_months: parseInt(e.target.value) || undefined })}
|
||||
min={1}
|
||||
placeholder="z.B. 24"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Zweck & Art. 22 */}
|
||||
{activeTab === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck *</label>
|
||||
<textarea
|
||||
value={module.processing_purpose}
|
||||
onChange={e => update({ processing_purpose: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Welchem Zweck dient dieses KI-System? Was soll erreicht werden?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsgrundlage *</label>
|
||||
<select
|
||||
value={module.legal_basis}
|
||||
onChange={e => update({ legal_basis: e.target.value })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{LEGAL_BASES.map(lb => (
|
||||
<option key={lb} value={lb}>{lb}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{module.legal_basis && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Details zur Rechtsgrundlage</label>
|
||||
<textarea
|
||||
value={module.legal_basis_details || ''}
|
||||
onChange={e => update({ legal_basis_details: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Ergänzende Erläuterung zur Rechtsgrundlage..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Art. 22 Panel */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Art. 22 DSGVO – Automatisierte Einzelentscheidung
|
||||
{art22Required && (
|
||||
<span className="ml-2 text-xs px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
|
||||
Wahrscheinlich anwendbar
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<Art22AssessmentPanel
|
||||
assessment={module.art22_assessment}
|
||||
onChange={a22 => update({ art22_assessment: a22 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 4: KI-Kriterien & AI Act */}
|
||||
{activeTab === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">WP248-Risikokriterien</h4>
|
||||
<AIRiskCriteriaChecklist
|
||||
criteria={module.risk_criteria}
|
||||
onChange={criteria => update({ risk_criteria: criteria })}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">EU AI Act – Risikoklasse</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(Object.entries(AI_ACT_RISK_CLASSES) as [AIActRiskClass, typeof AI_ACT_RISK_CLASSES[AIActRiskClass]][]).map(([cls, info]) => (
|
||||
<button
|
||||
key={cls}
|
||||
onClick={() => update({ ai_act_risk_class: cls })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
module.ai_act_risk_class === cls
|
||||
? cls === 'unacceptable' ? 'border-red-500 bg-red-50'
|
||||
: cls === 'high_risk' ? 'border-orange-500 bg-orange-50'
|
||||
: cls === 'limited' ? 'border-yellow-500 bg-yellow-50'
|
||||
: 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900">{info.labelDE}</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{info.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{module.ai_act_risk_class && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Begründung der Klassifizierung</label>
|
||||
<textarea
|
||||
value={module.ai_act_justification || ''}
|
||||
onChange={e => update({ ai_act_justification: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Warum wurde diese Risikoklasse gewählt?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{module.ai_act_risk_class && AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">Anforderungen dieser Klasse:</div>
|
||||
<ul className="space-y-1">
|
||||
{AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.map((req, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 flex items-start gap-1.5">
|
||||
<span className="text-purple-500 flex-shrink-0">•</span>
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 5: Risikoanalyse */}
|
||||
{activeTab === 5 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Spezifische Risiken für diesen KI-Anwendungsfall. Typische Risiken basierend auf dem gewählten Typ:
|
||||
</p>
|
||||
{typeInfo.typical_risks.length > 0 && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-xs font-medium text-yellow-800 mb-1">Typische Risiken für {typeInfo.label}:</div>
|
||||
<ul className="space-y-0.5">
|
||||
{typeInfo.typical_risks.map((r, i) => (
|
||||
<li key={i} className="text-xs text-yellow-700 flex items-center gap-1.5">
|
||||
<span>⚠️</span> {r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{(module.risks || []).map((risk, idx) => (
|
||||
<div key={idx} className="p-3 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-900">{risk.description}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded">W: {risk.likelihood}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">S: {risk.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update({ risks: module.risks.filter((_, i) => i !== idx) })}
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(module.risks || []).length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-4">Noch keine Risiken dokumentiert</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Add Risk */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const desc = prompt('Risiko-Beschreibung:')
|
||||
if (desc) {
|
||||
update({
|
||||
risks: [...(module.risks || []), {
|
||||
risk_id: crypto.randomUUID(),
|
||||
description: desc,
|
||||
likelihood: 'medium',
|
||||
impact: 'medium',
|
||||
mitigation_ids: [],
|
||||
}]
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-purple-400 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
+ Risiko hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 6: Maßnahmen & Privacy by Design */}
|
||||
{activeTab === 6 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Privacy by Design Maßnahmen</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.entries(PRIVACY_BY_DESIGN_CATEGORIES) as [PrivacyByDesignCategory, typeof PRIVACY_BY_DESIGN_CATEGORIES[PrivacyByDesignCategory]][]).map(([cat, info]) => {
|
||||
const measure = module.privacy_by_design_measures?.find(m => m.category === cat)
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => togglePbdMeasure(cat)}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border text-left transition-all ${
|
||||
measure?.implemented
|
||||
? 'border-green-400 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">{info.icon}</span>
|
||||
<div>
|
||||
<div className={`text-xs font-medium ${measure?.implemented ? 'text-green-800' : 'text-gray-700'}`}>
|
||||
{info.label}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{info.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 7: Review-Trigger */}
|
||||
{activeTab === 7 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Wählen Sie die Ereignisse, die eine erneute Bewertung dieses KI-Anwendungsfalls auslösen sollen.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{REVIEW_TRIGGER_TYPES.map(rt => {
|
||||
const active = module.review_triggers?.some(t => t.type === rt.value)
|
||||
const trigger = module.review_triggers?.find(t => t.type === rt.value)
|
||||
return (
|
||||
<div key={rt.value} className={`rounded-lg border p-3 transition-all ${active ? 'border-purple-300 bg-purple-50' : 'border-gray-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active || false}
|
||||
onChange={() => toggleReviewTrigger(rt.value)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<span className="text-base">{rt.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{rt.label}</span>
|
||||
</div>
|
||||
{active && (
|
||||
<div className="mt-2 ml-7 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={trigger?.threshold || ''}
|
||||
onChange={e => {
|
||||
const updated = (module.review_triggers || []).map(t =>
|
||||
t.type === rt.value ? { ...t, threshold: e.target.value } : t
|
||||
)
|
||||
update({ review_triggers: updated })
|
||||
}}
|
||||
placeholder="Schwellwert (z.B. Genauigkeit < 80%)"
|
||||
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={trigger?.monitoring_interval || ''}
|
||||
onChange={e => {
|
||||
const updated = (module.review_triggers || []).map(t =>
|
||||
t.type === rt.value ? { ...t, monitoring_interval: e.target.value } : t
|
||||
)
|
||||
update({ review_triggers: updated })
|
||||
}}
|
||||
placeholder="Monitoring-Intervall (z.B. wöchentlich)"
|
||||
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Monitoring-Beschreibung</label>
|
||||
<textarea
|
||||
value={module.monitoring_description || ''}
|
||||
onChange={e => update({ monitoring_description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Wie wird das KI-System kontinuierlich überwacht? Welche Metriken werden erfasst?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nächstes Review-Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={module.next_review_date || ''}
|
||||
onChange={e => update({ next_review_date: e.target.value })}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex gap-2">
|
||||
{activeTab > 1 && (
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab - 1)}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
)}
|
||||
{activeTab < 7 && (
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab + 1)}
|
||||
className="px-4 py-2 text-sm bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(module)}
|
||||
disabled={isSaving || !module.name || !module.model_description}
|
||||
className="px-6 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors font-medium"
|
||||
>
|
||||
{isSaving ? 'Speichert...' : 'Modul speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
264
admin-compliance/components/sdk/dsfa/AIUseCaseSection.tsx
Normal file
264
admin-compliance/components/sdk/dsfa/AIUseCaseSection.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { DSFA } from '@/lib/sdk/dsfa/types'
|
||||
import {
|
||||
AIUseCaseModule,
|
||||
AIUseCaseType,
|
||||
AI_USE_CASE_TYPES,
|
||||
AI_ACT_RISK_CLASSES,
|
||||
createEmptyModule,
|
||||
calculateModuleRiskLevel,
|
||||
getModuleCompletionPercentage,
|
||||
} from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
import { AIUseCaseTypeSelector } from './AIUseCaseTypeSelector'
|
||||
import { AIUseCaseModuleEditor } from './AIUseCaseModuleEditor'
|
||||
|
||||
interface AIUseCaseSectionProps {
|
||||
dsfa: DSFA
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
const RISK_LEVEL_LABELS: Record<string, string> = {
|
||||
low: 'Niedrig',
|
||||
medium: 'Mittel',
|
||||
high: 'Hoch',
|
||||
very_high: 'Sehr hoch',
|
||||
}
|
||||
|
||||
const RISK_LEVEL_COLORS: Record<string, string> = {
|
||||
low: 'bg-green-100 text-green-700 border-green-200',
|
||||
medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
high: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
very_high: 'bg-red-100 text-red-700 border-red-200',
|
||||
}
|
||||
|
||||
export function AIUseCaseSection({ dsfa, onUpdate, isSubmitting }: AIUseCaseSectionProps) {
|
||||
const [showTypeSelector, setShowTypeSelector] = useState(false)
|
||||
const [editingModule, setEditingModule] = useState<AIUseCaseModule | null>(null)
|
||||
const [isSavingModule, setIsSavingModule] = useState(false)
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||
|
||||
const modules = dsfa.ai_use_case_modules || []
|
||||
|
||||
// Aggregate AI risk overview
|
||||
const highRiskCount = modules.filter(m => {
|
||||
const level = calculateModuleRiskLevel(m)
|
||||
return level === 'high' || level === 'very_high'
|
||||
}).length
|
||||
|
||||
const art22Count = modules.filter(m => m.art22_assessment?.applies).length
|
||||
|
||||
const handleSelectType = (type: AIUseCaseType) => {
|
||||
setShowTypeSelector(false)
|
||||
const newModule = createEmptyModule(type)
|
||||
setEditingModule(newModule)
|
||||
}
|
||||
|
||||
const handleSaveModule = async (savedModule: AIUseCaseModule) => {
|
||||
setIsSavingModule(true)
|
||||
try {
|
||||
const existing = dsfa.ai_use_case_modules || []
|
||||
const idx = existing.findIndex(m => m.id === savedModule.id)
|
||||
const updated = idx >= 0
|
||||
? existing.map(m => m.id === savedModule.id ? savedModule : m)
|
||||
: [...existing, savedModule]
|
||||
|
||||
await onUpdate({ ai_use_case_modules: updated })
|
||||
setEditingModule(null)
|
||||
} finally {
|
||||
setIsSavingModule(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteModule = async (moduleId: string) => {
|
||||
const updated = modules.filter(m => m.id !== moduleId)
|
||||
await onUpdate({ ai_use_case_modules: updated })
|
||||
setConfirmDeleteId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header & Info */}
|
||||
<div className="p-4 bg-purple-50 border border-purple-200 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-purple-900">KI-Anwendungsfälle (Section 8)</h3>
|
||||
<p className="text-sm text-purple-700 mt-1">
|
||||
Dokumentieren Sie KI-spezifische Verarbeitungen mit modularen Anhängen nach Art. 35 DSGVO,
|
||||
Art. 22 DSGVO und den Transparenzanforderungen des EU AI Act.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning: involves_ai but no modules */}
|
||||
{dsfa.involves_ai && modules.length === 0 && (
|
||||
<div className="flex items-start gap-3 p-4 bg-yellow-50 border border-yellow-300 rounded-xl">
|
||||
<svg className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<p className="text-sm font-medium text-yellow-800">KI-Einsatz dokumentiert, aber keine Module vorhanden</p>
|
||||
<p className="text-xs text-yellow-700 mt-0.5">
|
||||
In Section 3 wurde KI-Einsatz festgestellt. Fügen Sie mindestens ein KI-Modul hinzu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aggregated Risk Overview (only when modules exist) */}
|
||||
{modules.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-white border border-gray-200 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-purple-700">{modules.length}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">KI-Module</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl text-center border ${highRiskCount > 0 ? 'bg-red-50 border-red-200' : 'bg-green-50 border-green-200'}`}>
|
||||
<div className={`text-2xl font-bold ${highRiskCount > 0 ? 'text-red-700' : 'text-green-700'}`}>{highRiskCount}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Hoch-Risiko-Module</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl text-center border ${art22Count > 0 ? 'bg-orange-50 border-orange-200' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className={`text-2xl font-bold ${art22Count > 0 ? 'text-orange-700' : 'text-gray-400'}`}>{art22Count}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Art. 22 DSGVO</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module Cards */}
|
||||
<div className="space-y-3">
|
||||
{modules.map(module => {
|
||||
const riskLevel = calculateModuleRiskLevel(module)
|
||||
const completion = getModuleCompletionPercentage(module)
|
||||
const typeInfo = AI_USE_CASE_TYPES[module.use_case_type]
|
||||
const aiActInfo = AI_ACT_RISK_CLASSES[module.ai_act_risk_class]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={module.id}
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl flex-shrink-0">{typeInfo.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900">{module.name}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded border ${RISK_LEVEL_COLORS[riskLevel]}`}>
|
||||
{RISK_LEVEL_LABELS[riskLevel]}
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
{module.art22_assessment?.applies && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded border border-orange-200">
|
||||
Art. 22
|
||||
</span>
|
||||
)}
|
||||
{module.ai_act_risk_class !== 'minimal' && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded border ${
|
||||
module.ai_act_risk_class === 'unacceptable' ? 'bg-red-100 text-red-700 border-red-200' :
|
||||
module.ai_act_risk_class === 'high_risk' ? 'bg-orange-100 text-orange-700 border-orange-200' :
|
||||
'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
}`}>
|
||||
AI Act: {aiActInfo.labelDE}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{module.model_description && (
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-1">{module.model_description}</p>
|
||||
)}
|
||||
{/* Progress Bar */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 transition-all"
|
||||
style={{ width: `${completion}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{completion}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setEditingModule(module)}
|
||||
className="px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(module.id)}
|
||||
className="px-2 py-1.5 text-xs text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{confirmDeleteId === module.id && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-700 mb-2">Modul wirklich löschen?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDeleteModule(module.id)}
|
||||
disabled={isSubmitting}
|
||||
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
className="px-3 py-1 text-xs bg-white border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Empty State */}
|
||||
{modules.length === 0 && !dsfa.involves_ai && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<div className="text-4xl mb-2">🤖</div>
|
||||
<p className="text-sm">Keine KI-Anwendungsfälle dokumentiert</p>
|
||||
<p className="text-xs mt-1">Optional: Fügen Sie KI-Module hinzu, wenn dieses DSFA KI-Einsatz umfasst</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Module Button */}
|
||||
<button
|
||||
onClick={() => setShowTypeSelector(true)}
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3 border-2 border-dashed border-purple-300 rounded-xl text-sm text-purple-600 hover:border-purple-500 hover:bg-purple-50 transition-all font-medium"
|
||||
>
|
||||
+ KI-Anwendungsfall-Modul hinzufügen
|
||||
</button>
|
||||
|
||||
{/* Type Selector Modal */}
|
||||
{showTypeSelector && (
|
||||
<AIUseCaseTypeSelector
|
||||
onSelect={handleSelectType}
|
||||
onCancel={() => setShowTypeSelector(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Module Editor Modal */}
|
||||
{editingModule && (
|
||||
<AIUseCaseModuleEditor
|
||||
module={editingModule}
|
||||
onSave={handleSaveModule}
|
||||
onCancel={() => setEditingModule(null)}
|
||||
isSaving={isSavingModule}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AI_USE_CASE_TYPES, AIUseCaseType } from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
|
||||
interface AIUseCaseTypeSelectorProps {
|
||||
onSelect: (type: AIUseCaseType) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function AIUseCaseTypeSelector({ onSelect, onCancel }: AIUseCaseTypeSelectorProps) {
|
||||
const types = Object.entries(AI_USE_CASE_TYPES) as [AIUseCaseType, typeof AI_USE_CASE_TYPES[AIUseCaseType]][]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">KI-Anwendungsfall hinzufügen</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Wählen Sie den Typ des KI-Systems für diesen Anhang</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-400 hover:text-gray-600 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type Grid */}
|
||||
<div className="p-6 grid grid-cols-2 gap-3 max-h-[60vh] overflow-y-auto">
|
||||
{types.map(([type, info]) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => onSelect(type)}
|
||||
className="flex items-start gap-3 p-4 rounded-xl border-2 border-gray-200 hover:border-purple-400 hover:bg-purple-50 text-left transition-all group"
|
||||
>
|
||||
<span className="text-2xl flex-shrink-0">{info.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 group-hover:text-purple-700">{info.label}</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{info.description}</p>
|
||||
{info.typical_risks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{info.typical_risks.slice(0, 2).map(risk => (
|
||||
<span key={risk} className="text-[10px] px-1.5 py-0.5 bg-red-50 text-red-600 rounded">
|
||||
{risk}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
149
admin-compliance/components/sdk/dsfa/Art22AssessmentPanel.tsx
Normal file
149
admin-compliance/components/sdk/dsfa/Art22AssessmentPanel.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Art22Assessment, Art22ExceptionType } from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
|
||||
interface Art22AssessmentPanelProps {
|
||||
assessment: Art22Assessment
|
||||
onChange: (updated: Art22Assessment) => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const EXCEPTION_OPTIONS: { value: Art22ExceptionType; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'contract',
|
||||
label: 'Art. 22 Abs. 2 lit. a – Vertragserfüllung',
|
||||
description: 'Die Entscheidung ist für den Abschluss oder die Erfüllung eines Vertrags erforderlich',
|
||||
},
|
||||
{
|
||||
value: 'legal',
|
||||
label: 'Art. 22 Abs. 2 lit. b – Rechtliche Verpflichtung',
|
||||
description: 'Die Entscheidung ist durch Unionsrecht oder mitgliedstaatliches Recht zugelassen',
|
||||
},
|
||||
{
|
||||
value: 'consent',
|
||||
label: 'Art. 22 Abs. 2 lit. c – Ausdrückliche Einwilligung',
|
||||
description: 'Die betroffene Person hat ausdrücklich eingewilligt',
|
||||
},
|
||||
]
|
||||
|
||||
export function Art22AssessmentPanel({ assessment, onChange, readonly }: Art22AssessmentPanelProps) {
|
||||
const missingRequiredSafeguards = assessment.applies &&
|
||||
!assessment.safeguards.some(s => s.id === 'human_review' && s.implemented)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Art. 22 Toggle */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl border border-gray-200 bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="art22_applies"
|
||||
checked={assessment.applies}
|
||||
onChange={e => onChange({ ...assessment, applies: e.target.checked })}
|
||||
disabled={readonly}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<label htmlFor="art22_applies" className="flex-1">
|
||||
<div className="font-medium text-gray-900">
|
||||
Automatisierte Einzelentscheidung mit Rechtswirkung (Art. 22 DSGVO)
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Das KI-System trifft Entscheidungen, die rechtliche Wirkung oder ähnlich erhebliche
|
||||
Auswirkungen auf Personen haben, ohne maßgebliche menschliche Beteiligung.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Warning if applies without safeguards */}
|
||||
{missingRequiredSafeguards && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<p className="text-sm text-red-700">
|
||||
Art. 22 gilt als anwendbar, aber die erforderliche Schutzmaßnahme „Recht auf menschliche Überprüfung" ist nicht implementiert.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assessment.applies && (
|
||||
<>
|
||||
{/* Justification */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Begründung der Ausnahme (Art. 22 Abs. 2)
|
||||
</label>
|
||||
<textarea
|
||||
value={assessment.justification || ''}
|
||||
onChange={e => onChange({ ...assessment, justification: e.target.value })}
|
||||
disabled={readonly}
|
||||
rows={3}
|
||||
placeholder="Begründen Sie, auf welcher Rechtsgrundlage die automatisierte Entscheidung zulässig ist..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Exception Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Ausnahmetatbestand</label>
|
||||
<div className="space-y-2">
|
||||
{EXCEPTION_OPTIONS.map(opt => (
|
||||
<label key={opt.value} className="flex items-start gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="art22_exception"
|
||||
value={opt.value}
|
||||
checked={assessment.exception_type === opt.value}
|
||||
onChange={() => onChange({ ...assessment, exception_type: opt.value })}
|
||||
disabled={readonly}
|
||||
className="mt-0.5 h-4 w-4 text-purple-600"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{opt.label}</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{opt.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Safeguards */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Schutzmaßnahmen (Art. 22 Abs. 3 DSGVO)
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{assessment.safeguards.map((safeguard, idx) => (
|
||||
<label key={safeguard.id} className="flex items-start gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={safeguard.implemented}
|
||||
onChange={e => {
|
||||
const updated = assessment.safeguards.map((s, i) =>
|
||||
i === idx ? { ...s, implemented: e.target.checked } : s
|
||||
)
|
||||
onChange({ ...assessment, safeguards: updated })
|
||||
}}
|
||||
disabled={readonly}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${safeguard.id === 'human_review' && !safeguard.implemented ? 'text-red-700' : 'text-gray-900'}`}>
|
||||
{safeguard.label}
|
||||
{safeguard.id === 'human_review' && (
|
||||
<span className="ml-1 text-xs text-red-500">*Pflicht</span>
|
||||
)}
|
||||
</div>
|
||||
{safeguard.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{safeguard.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -70,6 +70,15 @@ function calculateSectionProgress(dsfa: DSFA, sectionNumber: number): number {
|
||||
if (rs.next_review_date) return 50
|
||||
return 25
|
||||
|
||||
case 8: // KI-Anwendungsfälle (optional)
|
||||
const aiModules = dsfa.ai_use_case_modules || []
|
||||
if (aiModules.length === 0) return 0
|
||||
const avgCompletion = aiModules.reduce((sum, m) => {
|
||||
const checks = [!!m.name, !!m.model_description, m.input_data_categories?.length > 0, !!m.processing_purpose, !!m.legal_basis, !!m.ai_act_risk_class]
|
||||
return sum + Math.round((checks.filter(Boolean).length / checks.length) * 100)
|
||||
}, 0) / aiModules.length
|
||||
return Math.round(avgCompletion)
|
||||
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
@@ -87,6 +96,7 @@ function isSectionComplete(dsfa: DSFA, sectionNumber: number): boolean {
|
||||
case 5: return progress.section_5_complete ?? false
|
||||
case 6: return progress.section_6_complete ?? false
|
||||
case 7: return progress.section_7_complete ?? false
|
||||
case 8: return progress.section_8_complete ?? false
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
@@ -112,6 +122,7 @@ export function DSFASidebar({ dsfa, activeSection, onSectionChange }: DSFASideba
|
||||
const stakeholderSection = DSFA_SECTIONS.find(s => s.number === 5)
|
||||
const consultationSection = DSFA_SECTIONS.find(s => s.number === 6)
|
||||
const reviewSection = DSFA_SECTIONS.find(s => s.number === 7)
|
||||
const aiSection = DSFA_SECTIONS.find(s => s.number === 8)
|
||||
|
||||
const renderSectionItem = (section: typeof DSFA_SECTIONS[0]) => {
|
||||
const progress = calculateSectionProgress(dsfa, section.number)
|
||||
@@ -230,13 +241,21 @@ export function DSFASidebar({ dsfa, activeSection, onSectionChange }: DSFASideba
|
||||
</div>
|
||||
|
||||
{/* Section 7: Review */}
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
|
||||
Fortschreibung
|
||||
</div>
|
||||
{reviewSection && renderSectionItem(reviewSection)}
|
||||
</div>
|
||||
|
||||
{/* Section 8: KI-Anwendungsfälle */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
|
||||
KI & AI Act
|
||||
</div>
|
||||
{aiSection && renderSectionItem(aiSection)}
|
||||
</div>
|
||||
|
||||
{/* Status Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
|
||||
@@ -23,6 +23,7 @@ const TRIGGER_TYPES = [
|
||||
{ value: 'new_purpose', label: 'Neuer Zweck', description: 'Aenderung oder Erweiterung des Verarbeitungszwecks', icon: '🎯' },
|
||||
{ value: 'incident', label: 'Sicherheitsvorfall', description: 'Datenschutzvorfall oder Sicherheitsproblem', icon: '🚨' },
|
||||
{ value: 'regulatory', label: 'Regulatorisch', description: 'Gesetzes- oder Behoerden-Aenderung', icon: '📜' },
|
||||
{ value: 'ai_use_case_module', label: 'KI-Modul-Änderung', description: 'Änderung eines KI-Anwendungsfalls (Modell-Update, Datendrift)', icon: '🤖' },
|
||||
{ value: 'other', label: 'Sonstiges', description: 'Anderer Ausloser', icon: '📋' },
|
||||
]
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ export { StakeholderConsultationSection } from './StakeholderConsultationSection
|
||||
export { Art36Warning } from './Art36Warning'
|
||||
export { ReviewScheduleSection } from './ReviewScheduleSection'
|
||||
export { SourceAttribution, InlineSourceRef, AttributionFooter } from './SourceAttribution'
|
||||
export { AIUseCaseSection } from './AIUseCaseSection'
|
||||
export { AIUseCaseModuleEditor } from './AIUseCaseModuleEditor'
|
||||
export { AIUseCaseTypeSelector } from './AIUseCaseTypeSelector'
|
||||
export { Art22AssessmentPanel } from './Art22AssessmentPanel'
|
||||
export { AIRiskCriteriaChecklist } from './AIRiskCriteriaChecklist'
|
||||
|
||||
// =============================================================================
|
||||
// DSFA Card Component
|
||||
|
||||
Reference in New Issue
Block a user