feat(admin-v2): Major SDK/Compliance overhaul and new modules
SDK modules added/enhanced: - compliance-hub, compliance-scope, consent-management, notfallplan - audit-report, workflow, source-policy, dsms - advisory-board documentation section - TOM dashboard components, TOM generator SDM mapping - DSFA: mitigation library, risk catalog, threshold analysis, source attribution - VVT: baseline catalog, profiling engine, types - Loeschfristen: baseline catalog, compliance engine, export, profiling, types - Compliance scope: engine, profiling, golden tests, types Existing SDK pages updated: - dsfa/[id], tom, vvt, loeschfristen, advisory-board — expanded functionality - SDKSidebar, StepHeader — new navigation items and layout - SDK layout, context, types — expanded type system Other admin-v2 changes: - AI agents page, RAG pipeline DSFA integration - GridOverlay component updates - Companion feature (development + education) - Compliance advisor SOUL definition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,27 @@ import {
|
||||
DSFA_LEGAL_BASES,
|
||||
DSFA_AFFECTED_RIGHTS,
|
||||
calculateRiskLevel,
|
||||
SDM_GOALS,
|
||||
} from '@/lib/sdk/dsfa/types'
|
||||
import type { SDMGoal, DSFARiskCategory } from '@/lib/sdk/dsfa/types'
|
||||
import {
|
||||
RISK_CATALOG,
|
||||
RISK_CATEGORY_LABELS,
|
||||
COMPONENT_FAMILY_LABELS,
|
||||
getRisksByCategory,
|
||||
getRisksBySDMGoal,
|
||||
} from '@/lib/sdk/dsfa/risk-catalog'
|
||||
import type { CatalogRisk } from '@/lib/sdk/dsfa/risk-catalog'
|
||||
import {
|
||||
MITIGATION_LIBRARY,
|
||||
MITIGATION_TYPE_LABELS,
|
||||
SDM_GOAL_LABELS,
|
||||
EFFECTIVENESS_LABELS,
|
||||
getMitigationsBySDMGoal,
|
||||
getMitigationsByType,
|
||||
getMitigationsForRisk,
|
||||
} from '@/lib/sdk/dsfa/mitigation-library'
|
||||
import type { CatalogMitigation } from '@/lib/sdk/dsfa/mitigation-library'
|
||||
import {
|
||||
getDSFA,
|
||||
updateDSFASection,
|
||||
@@ -32,6 +52,8 @@ import {
|
||||
Art36Warning,
|
||||
ReviewScheduleSection,
|
||||
} from '@/components/sdk/dsfa'
|
||||
import { SourceAttribution } from '@/components/sdk/dsfa/SourceAttribution'
|
||||
import type { DSFALicenseCode, SourceAttributionProps } from '@/lib/sdk/types'
|
||||
|
||||
// =============================================================================
|
||||
// SECTION EDITORS
|
||||
@@ -483,6 +505,12 @@ function Section3Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RAG Search for Risks */}
|
||||
<RAGSearchPanel
|
||||
context={`Risiken Datenschutz-Folgenabschaetzung ${dsfa.processing_description || ''} ${dsfa.processing_purpose || ''}`}
|
||||
categories={['risk_assessment', 'threshold_analysis']}
|
||||
/>
|
||||
|
||||
{/* Affected Rights */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Betroffene Rechte & Freiheiten</h4>
|
||||
@@ -648,6 +676,12 @@ function Section4Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
|
||||
'bg-purple-50'
|
||||
)}
|
||||
|
||||
{/* RAG Search for Mitigations */}
|
||||
<RAGSearchPanel
|
||||
context={`Massnahmen Datenschutz-Folgenabschaetzung ${dsfa.processing_description || ''} ${dsfa.processing_purpose || ''}`}
|
||||
categories={['mitigation', 'risk_assessment']}
|
||||
/>
|
||||
|
||||
{/* TOM References */}
|
||||
{dsfa.tom_references && dsfa.tom_references.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
@@ -837,6 +871,305 @@ function Section5Editor({ dsfa, onUpdate, isSubmitting }: SectionProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SDM COVERAGE OVERVIEW
|
||||
// =============================================================================
|
||||
|
||||
function SDMCoverageOverview({ dsfa }: { dsfa: DSFA }) {
|
||||
const goals = Object.keys(SDM_GOALS) as SDMGoal[]
|
||||
const riskCount = dsfa.risks?.length || 0
|
||||
const mitigationCount = dsfa.mitigations?.length || 0
|
||||
|
||||
// Count catalog risks and mitigations per SDM goal by matching descriptions
|
||||
const goalCoverage = goals.map(goal => {
|
||||
const catalogRisks = RISK_CATALOG.filter(r => r.sdmGoal === goal)
|
||||
const catalogMitigations = MITIGATION_LIBRARY.filter(m => m.sdmGoals.includes(goal))
|
||||
|
||||
// Check if any DSFA risk descriptions contain catalog risk titles for this goal
|
||||
const matchedRisks = catalogRisks.filter(cr =>
|
||||
dsfa.risks?.some(r => r.description?.includes(cr.title))
|
||||
).length
|
||||
|
||||
// Check if any DSFA mitigation descriptions contain catalog mitigation titles for this goal
|
||||
const matchedMitigations = catalogMitigations.filter(cm =>
|
||||
dsfa.mitigations?.some(m => m.description?.includes(cm.title))
|
||||
).length
|
||||
|
||||
const totalCatalogRisks = catalogRisks.length
|
||||
const totalCatalogMitigations = catalogMitigations.length
|
||||
|
||||
// Coverage: simple heuristic based on whether there are mitigations for risks in this area
|
||||
const hasRisks = matchedRisks > 0 || dsfa.risks?.some(r => {
|
||||
const cat = r.category
|
||||
if (goal === 'vertraulichkeit' && cat === 'confidentiality') return true
|
||||
if (goal === 'integritaet' && cat === 'integrity') return true
|
||||
if (goal === 'verfuegbarkeit' && cat === 'availability') return true
|
||||
if (goal === 'nichtverkettung' && cat === 'rights_freedoms') return true
|
||||
return false
|
||||
})
|
||||
|
||||
const coverage = matchedMitigations > 0 ? 'covered' :
|
||||
hasRisks ? 'gaps' : 'no_data'
|
||||
|
||||
return {
|
||||
goal,
|
||||
info: SDM_GOALS[goal],
|
||||
matchedRisks,
|
||||
matchedMitigations,
|
||||
totalCatalogRisks,
|
||||
totalCatalogMitigations,
|
||||
coverage,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mt-6 bg-white rounded-xl border p-6">
|
||||
<h3 className="text-md font-semibold text-gray-900 mb-1">SDM-Abdeckung (Gewaehrleistungsziele)</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Uebersicht ueber die Abdeckung der 7 Gewaehrleistungsziele des Standard-Datenschutzmodells.</p>
|
||||
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{goalCoverage.map(({ goal, info, matchedRisks, matchedMitigations, coverage }) => (
|
||||
<div
|
||||
key={goal}
|
||||
className={`p-3 rounded-lg text-center border ${
|
||||
coverage === 'covered' ? 'bg-green-50 border-green-200' :
|
||||
coverage === 'gaps' ? 'bg-yellow-50 border-yellow-200' :
|
||||
'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-lg mb-1 ${
|
||||
coverage === 'covered' ? 'text-green-600' :
|
||||
coverage === 'gaps' ? 'text-yellow-600' :
|
||||
'text-gray-400'
|
||||
}`}>
|
||||
{coverage === 'covered' ? '\u2713' : coverage === 'gaps' ? '!' : '\u2013'}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-900 leading-tight">{info.name}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
{matchedRisks}R / {matchedMitigations}M
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500"></span> Abgedeckt</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-yellow-500"></span> Luecken</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-gray-300"></span> Keine Daten</span>
|
||||
<span className="ml-auto">R = Risiken, M = Massnahmen</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RAG SEARCH PANEL
|
||||
// =============================================================================
|
||||
|
||||
const RAG_API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
interface RAGSearchResult {
|
||||
chunk_id: string
|
||||
content: string
|
||||
score: number
|
||||
source_code: string
|
||||
source_name: string
|
||||
attribution_text: string
|
||||
license_code: string
|
||||
license_name: string
|
||||
license_url?: string
|
||||
source_url?: string
|
||||
document_type?: string
|
||||
category?: string
|
||||
section_title?: string
|
||||
}
|
||||
|
||||
interface RAGSearchResponse {
|
||||
query: string
|
||||
results: RAGSearchResult[]
|
||||
total_results: number
|
||||
licenses_used: string[]
|
||||
attribution_notice: string
|
||||
}
|
||||
|
||||
function RAGSearchPanel({
|
||||
context,
|
||||
categories,
|
||||
onInsertText,
|
||||
}: {
|
||||
context: string
|
||||
categories?: string[]
|
||||
onInsertText?: (text: string) => void
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [results, setResults] = useState<RAGSearchResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
const buildQuery = () => {
|
||||
if (query.trim()) return query.trim()
|
||||
// Auto-generate query from context
|
||||
return context.substring(0, 200)
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const searchQuery = buildQuery()
|
||||
if (!searchQuery || searchQuery.length < 3) return
|
||||
|
||||
setIsSearching(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ query: searchQuery, limit: '5' })
|
||||
if (categories?.length) {
|
||||
categories.forEach(c => params.append('categories', c))
|
||||
}
|
||||
|
||||
const response = await fetch(`${RAG_API_BASE}/api/v1/dsfa-rag/search?${params}`)
|
||||
if (!response.ok) throw new Error(`Suche fehlgeschlagen (${response.status})`)
|
||||
const data: RAGSearchResponse = await response.json()
|
||||
setResults(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Suche fehlgeschlagen')
|
||||
setResults(null)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInsert = (text: string, chunkId: string) => {
|
||||
if (onInsertText) {
|
||||
onInsertText(text)
|
||||
} else {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
setCopiedId(chunkId)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
const sourcesForAttribution: SourceAttributionProps['sources'] = (results?.results || []).map(r => ({
|
||||
sourceCode: r.source_code,
|
||||
sourceName: r.source_name,
|
||||
attributionText: r.attribution_text,
|
||||
licenseCode: r.license_code as DSFALicenseCode,
|
||||
sourceUrl: r.source_url,
|
||||
score: r.score,
|
||||
}))
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-indigo-50 text-indigo-700 rounded-lg border border-indigo-200 hover:bg-indigo-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Empfehlung suchen (RAG)
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-indigo-800 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
DSFA-Wissenssuche (RAG)
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); setResults(null); setError(null) }}
|
||||
className="text-indigo-400 hover:text-indigo-600"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder={`Suchbegriff (oder leer fuer automatische Kontextsuche)...`}
|
||||
className="flex-1 px-3 py-2 text-sm border border-indigo-300 rounded-lg bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSearching ? 'Suche...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results && results.results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-indigo-600">{results.total_results} Ergebnis(se) gefunden</p>
|
||||
|
||||
{results.results.map(r => (
|
||||
<div key={r.chunk_id} className="bg-white rounded-lg border border-indigo-100 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{r.section_title && (
|
||||
<div className="text-xs font-medium text-indigo-600 mb-1">{r.section_title}</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">
|
||||
{r.content.length > 400 ? r.content.substring(0, 400) + '...' : r.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-gray-400 font-mono">
|
||||
{r.source_code} ({(r.score * 100).toFixed(0)}%)
|
||||
</span>
|
||||
{r.category && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{r.category}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInsert(r.content, r.chunk_id)}
|
||||
className={`flex-shrink-0 px-3 py-1.5 text-xs rounded-lg transition-colors ${
|
||||
copiedId === r.chunk_id
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
|
||||
}`}
|
||||
title="In Beschreibung uebernehmen"
|
||||
>
|
||||
{copiedId === r.chunk_id ? 'Kopiert!' : 'Uebernehmen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Source Attribution */}
|
||||
<SourceAttribution sources={sourcesForAttribution} compact showScores />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && results.results.length === 0 && (
|
||||
<div className="text-sm text-indigo-600 text-center py-4">
|
||||
Keine Ergebnisse gefunden. Versuchen Sie einen anderen Suchbegriff.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODALS
|
||||
// =============================================================================
|
||||
@@ -852,14 +1185,29 @@ function AddRiskModal({
|
||||
onClose: () => void
|
||||
onAdd: (data: { category: string; description: string }) => void
|
||||
}) {
|
||||
const [mode, setMode] = useState<'catalog' | 'manual'>('catalog')
|
||||
const [category, setCategory] = useState('confidentiality')
|
||||
const [description, setDescription] = useState('')
|
||||
const [catalogFilter, setCatalogFilter] = useState<DSFARiskCategory | 'all'>('all')
|
||||
const [sdmFilter, setSdmFilter] = useState<SDMGoal | 'all'>('all')
|
||||
|
||||
const { level } = calculateRiskLevel(likelihood, impact)
|
||||
|
||||
const filteredCatalog = RISK_CATALOG.filter(r => {
|
||||
if (catalogFilter !== 'all' && r.category !== catalogFilter) return false
|
||||
if (sdmFilter !== 'all' && r.sdmGoal !== sdmFilter) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function selectCatalogRisk(risk: CatalogRisk) {
|
||||
setCategory(risk.category)
|
||||
setDescription(`${risk.title}\n\n${risk.description}`)
|
||||
setMode('manual')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Risiko hinzufuegen</h3>
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-gray-50">
|
||||
@@ -872,33 +1220,107 @@ function AddRiskModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="confidentiality">Vertraulichkeit</option>
|
||||
<option value="integrity">Integritaet</option>
|
||||
<option value="availability">Verfuegbarkeit</option>
|
||||
<option value="rights_freedoms">Rechte & Freiheiten</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie das Risiko..."
|
||||
/>
|
||||
</div>
|
||||
{/* Tab Toggle */}
|
||||
<div className="flex border-b mb-4">
|
||||
<button
|
||||
onClick={() => setMode('catalog')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
mode === 'catalog' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Aus Katalog waehlen ({RISK_CATALOG.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
mode === 'manual' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Manuell eingeben
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'catalog' ? (
|
||||
<div className="space-y-3">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={catalogFilter}
|
||||
onChange={e => setCatalogFilter(e.target.value as DSFARiskCategory | 'all')}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(RISK_CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={sdmFilter}
|
||||
onChange={e => setSdmFilter(e.target.value as SDMGoal | 'all')}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">Alle SDM-Ziele</option>
|
||||
{Object.entries(SDM_GOAL_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Catalog List */}
|
||||
<div className="max-h-[40vh] overflow-y-auto space-y-2">
|
||||
{filteredCatalog.map(risk => (
|
||||
<button
|
||||
key={risk.id}
|
||||
onClick={() => selectCatalogRisk(risk)}
|
||||
className="w-full text-left p-3 rounded-lg border hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{risk.id}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
|
||||
{RISK_CATEGORY_LABELS[risk.category]}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600">
|
||||
{SDM_GOAL_LABELS[risk.sdmGoal]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">{risk.title}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{risk.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredCatalog.length === 0 && (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Keine Risiken fuer die gewaehlten Filter.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="confidentiality">Vertraulichkeit</option>
|
||||
<option value="integrity">Integritaet</option>
|
||||
<option value="availability">Verfuegbarkeit</option>
|
||||
<option value="rights_freedoms">Rechte & Freiheiten</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie das Risiko..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -908,7 +1330,7 @@ function AddRiskModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd({ category, description })}
|
||||
disabled={!description.trim()}
|
||||
disabled={!description.trim() || mode === 'catalog'}
|
||||
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Hinzufuegen
|
||||
@@ -928,68 +1350,170 @@ function AddMitigationModal({
|
||||
onClose: () => void
|
||||
onAdd: (data: { risk_id: string; description: string; type: string; responsible_party: string }) => void
|
||||
}) {
|
||||
const [mode, setMode] = useState<'library' | 'manual'>('library')
|
||||
const [riskId, setRiskId] = useState(risks[0]?.id || '')
|
||||
const [type, setType] = useState('technical')
|
||||
const [description, setDescription] = useState('')
|
||||
const [responsibleParty, setResponsibleParty] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'technical' | 'organizational' | 'legal'>('all')
|
||||
const [sdmFilter, setSdmFilter] = useState<SDMGoal | 'all'>('all')
|
||||
|
||||
const filteredLibrary = MITIGATION_LIBRARY.filter(m => {
|
||||
if (typeFilter !== 'all' && m.type !== typeFilter) return false
|
||||
if (sdmFilter !== 'all' && !m.sdmGoals.includes(sdmFilter)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function selectCatalogMitigation(m: CatalogMitigation) {
|
||||
setType(m.type)
|
||||
setDescription(`${m.title}\n\n${m.description}\n\nRechtsgrundlage: ${m.legalBasis}`)
|
||||
setMode('manual')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Massnahme hinzufuegen</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Zugehoeriges Risiko</label>
|
||||
<select
|
||||
value={riskId}
|
||||
onChange={(e) => setRiskId(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{risks.map(risk => (
|
||||
<option key={risk.id} value={risk.id}>
|
||||
{risk.description.substring(0, 50)}...
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Typ</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="technical">Technisch</option>
|
||||
<option value="organizational">Organisatorisch</option>
|
||||
<option value="legal">Rechtlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie die Massnahme..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Verantwortlich</label>
|
||||
<input
|
||||
type="text"
|
||||
value={responsibleParty}
|
||||
onChange={(e) => setResponsibleParty(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Rolle..."
|
||||
/>
|
||||
</div>
|
||||
{/* Tab Toggle */}
|
||||
<div className="flex border-b mb-4">
|
||||
<button
|
||||
onClick={() => setMode('library')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
mode === 'library' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Aus Bibliothek waehlen ({MITIGATION_LIBRARY.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||
mode === 'manual' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Manuell eingeben
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'library' ? (
|
||||
<div className="space-y-3">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value as typeof typeFilter)}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">Alle Typen</option>
|
||||
{Object.entries(MITIGATION_TYPE_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={sdmFilter}
|
||||
onChange={e => setSdmFilter(e.target.value as SDMGoal | 'all')}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">Alle SDM-Ziele</option>
|
||||
{Object.entries(SDM_GOAL_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Library List */}
|
||||
<div className="max-h-[40vh] overflow-y-auto space-y-2">
|
||||
{filteredLibrary.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => selectCatalogMitigation(m)}
|
||||
className="w-full text-left p-3 rounded-lg border hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
m.type === 'technical' ? 'bg-blue-50 text-blue-600' :
|
||||
m.type === 'organizational' ? 'bg-green-50 text-green-600' :
|
||||
'bg-purple-50 text-purple-600'
|
||||
}`}>
|
||||
{MITIGATION_TYPE_LABELS[m.type]}
|
||||
</span>
|
||||
{m.sdmGoals.map(g => (
|
||||
<span key={g} className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
|
||||
{SDM_GOAL_LABELS[g]}
|
||||
</span>
|
||||
))}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
m.effectiveness === 'high' ? 'bg-green-50 text-green-700' :
|
||||
m.effectiveness === 'medium' ? 'bg-yellow-50 text-yellow-700' :
|
||||
'bg-gray-50 text-gray-500'
|
||||
}`}>
|
||||
{EFFECTIVENESS_LABELS[m.effectiveness]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">{m.title}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 line-clamp-2">{m.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredLibrary.length === 0 && (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Keine Massnahmen fuer die gewaehlten Filter.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Zugehoeriges Risiko</label>
|
||||
<select
|
||||
value={riskId}
|
||||
onChange={(e) => setRiskId(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{risks.map(risk => (
|
||||
<option key={risk.id} value={risk.id}>
|
||||
{risk.description.substring(0, 50)}...
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Typ</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="technical">Technisch</option>
|
||||
<option value="organizational">Organisatorisch</option>
|
||||
<option value="legal">Rechtlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie die Massnahme..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Verantwortlich</label>
|
||||
<input
|
||||
type="text"
|
||||
value={responsibleParty}
|
||||
onChange={(e) => setResponsibleParty(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Rolle..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -999,7 +1523,7 @@ function AddMitigationModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd({ risk_id: riskId, description, type, responsible_party: responsibleParty })}
|
||||
disabled={!description.trim()}
|
||||
disabled={!description.trim() || mode === 'library'}
|
||||
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Hinzufuegen
|
||||
@@ -1264,6 +1788,11 @@ export default function DSFAEditorPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SDM Coverage Overview (shown in Section 3 and 4) */}
|
||||
{(activeSection === 3 || activeSection === 4) && (dsfa.risks?.length > 0 || dsfa.mitigations?.length > 0) && (
|
||||
<SDMCoverageOverview dsfa={dsfa} />
|
||||
)}
|
||||
|
||||
{/* Section 5: Stakeholder Consultation (NEW) */}
|
||||
{activeSection === 5 && (
|
||||
<StakeholderConsultationSection
|
||||
|
||||
Reference in New Issue
Block a user