feat(ucca): Pflichtendatenbank v2 (325 Obligations), Trigger-Engine, TOM-Control-Mapping
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 29s
CI / test-python-document-crawler (push) Successful in 20s
CI / test-python-dsms-gateway (push) Successful in 18s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 29s
CI / test-python-document-crawler (push) Successful in 20s
CI / test-python-dsms-gateway (push) Successful in 18s
- 9 Regulation-JSON-Dateien (DSGVO 80, AI Act 60, NIS2 40, BDSG 30, TTDSG 20, DSA 35, Data Act 25, EU-Maschinen 15, DORA 20) - Condition-Tree-Engine fuer automatische Pflichtenselektion (all_of/any_of, 80+ Field-Paths) - Generischer JSONRegulationModule-Loader mit YAML-Fallback - Bidirektionales TOM-Control-Mapping (291 Obligation→Control, 92 Control→Obligation) - Gap-Analyse-Engine (Compliance-%, Priority Actions, Domain Breakdown) - ScopeDecision→UnifiedFacts Bridge fuer Auto-Profiling - 4 neue API-Endpoints (assess-from-scope, tom-controls, gap-analysis, reverse-lookup) - Frontend: Auto-Profiling Button, Regulation-Filter Chips, TOM-Panel, Gap-Analyse-View - 18 Unit Tests (Condition Engine, v2 Loader, TOM Mapper) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
async function proxyRequest(request: NextRequest, method: string) {
|
||||
try {
|
||||
const pathSegments = request.nextUrl.pathname.replace('/api/sdk/v1/ucca/obligations/', '')
|
||||
const targetUrl = `${SDK_BASE_URL}/sdk/v1/ucca/obligations/${pathSegments}${request.nextUrl.search}`
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const tenantId = request.headers.get('X-Tenant-ID')
|
||||
if (tenantId) headers['X-Tenant-ID'] = tenantId
|
||||
|
||||
const fetchOptions: RequestInit = { method, headers }
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
fetchOptions.body = await request.text()
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'SDK backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('UCCA obligations proxy error:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to SDK backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) { return proxyRequest(request, 'GET') }
|
||||
export async function POST(request: NextRequest) { return proxyRequest(request, 'POST') }
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import TOMControlPanel from '@/components/sdk/obligations/TOMControlPanel'
|
||||
import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -178,7 +180,7 @@ function ObligationModal({
|
||||
onChange={e => update('source', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
{['DSGVO', 'AI Act', 'NIS2', 'BDSG', 'TTDSG', 'Sonstig'].map(s => (
|
||||
{['DSGVO', 'AI Act', 'NIS2', 'BDSG', 'TTDSG', 'DSA', 'Data Act', 'DORA', 'EU-Maschinen', 'Sonstig'].map(s => (
|
||||
<option key={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -426,6 +428,7 @@ function ObligationCard({
|
||||
onDetails: () => void
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showTOM, setShowTOM] = useState(false)
|
||||
|
||||
const daysUntil = obligation.deadline
|
||||
? Math.ceil((new Date(obligation.deadline).getTime() - Date.now()) / 86400000)
|
||||
@@ -480,6 +483,13 @@ function ObligationCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTOM(v => !v) }}
|
||||
className="px-2 py-1 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
title="TOM Controls anzeigen"
|
||||
>
|
||||
TOM
|
||||
</button>
|
||||
<button
|
||||
onClick={onDetails}
|
||||
className="text-purple-600 hover:text-purple-700 font-medium"
|
||||
@@ -497,6 +507,12 @@ function ObligationCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTOM && (
|
||||
<div className="mt-3" onClick={e => e.stopPropagation()}>
|
||||
<TOMControlPanel obligationId={obligation.id} onClose={() => setShowTOM(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -505,16 +521,42 @@ function ObligationCard({
|
||||
// Main Page
|
||||
// =============================================================================
|
||||
|
||||
function mapPriority(p: string): 'critical' | 'high' | 'medium' | 'low' {
|
||||
const map: Record<string, 'critical' | 'high' | 'medium' | 'low'> = {
|
||||
kritisch: 'critical', hoch: 'high', mittel: 'medium', niedrig: 'low',
|
||||
critical: 'critical', high: 'high', medium: 'medium', low: 'low',
|
||||
}
|
||||
return map[p?.toLowerCase()] || 'medium'
|
||||
}
|
||||
|
||||
const REGULATION_CHIPS = [
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'DSGVO', label: 'DSGVO' },
|
||||
{ key: 'AI Act', label: 'AI Act' },
|
||||
{ key: 'NIS2', label: 'NIS2' },
|
||||
{ key: 'BDSG', label: 'BDSG' },
|
||||
{ key: 'TTDSG', label: 'TTDSG' },
|
||||
{ key: 'DSA', label: 'DSA' },
|
||||
{ key: 'Data Act', label: 'Data Act' },
|
||||
{ key: 'DORA', label: 'DORA' },
|
||||
{ key: 'EU-Maschinen', label: 'EU-Maschinen' },
|
||||
]
|
||||
|
||||
const UCCA_API = '/api/sdk/v1/ucca/obligations'
|
||||
|
||||
export default function ObligationsPage() {
|
||||
const [obligations, setObligations] = useState<Obligation[]>([])
|
||||
const [stats, setStats] = useState<ObligationStats | null>(null)
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [regulationFilter, setRegulationFilter] = useState('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editObligation, setEditObligation] = useState<Obligation | null>(null)
|
||||
const [detailObligation, setDetailObligation] = useState<Obligation | null>(null)
|
||||
const [showGapAnalysis, setShowGapAnalysis] = useState(false)
|
||||
const [profiling, setProfiling] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -621,10 +663,68 @@ export default function ObligationsPage() {
|
||||
fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {})
|
||||
}
|
||||
|
||||
const handleAutoProfiling = async () => {
|
||||
setProfiling(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${UCCA_API}/assess-from-scope`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
employee_count: 50,
|
||||
industry: 'technology',
|
||||
processes_personal_data: true,
|
||||
is_controller: true,
|
||||
uses_processors: true,
|
||||
processes_special_categories: false,
|
||||
cross_border_transfer: true,
|
||||
uses_ai: true,
|
||||
determined_level: 'L2',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (data.obligations?.length > 0) {
|
||||
// Merge auto-profiled obligations into the view
|
||||
const autoObls: Obligation[] = data.obligations.map((o: Record<string, unknown>) => ({
|
||||
id: o.id as string,
|
||||
title: o.title as string,
|
||||
description: (o.description as string) || '',
|
||||
source: (o.regulation_id as string || '').toUpperCase(),
|
||||
source_article: '',
|
||||
deadline: null,
|
||||
status: 'pending' as const,
|
||||
priority: mapPriority(o.priority as string),
|
||||
responsible: (o.responsible as string) || '',
|
||||
linked_systems: [],
|
||||
rule_code: 'auto-profiled',
|
||||
}))
|
||||
setObligations(prev => {
|
||||
const existingIds = new Set(prev.map(p => p.id))
|
||||
const newOnes = autoObls.filter(a => !existingIds.has(a.id))
|
||||
return [...prev, ...newOnes]
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Auto-Profiling fehlgeschlagen')
|
||||
} finally {
|
||||
setProfiling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['obligations']
|
||||
|
||||
const filteredObligations = obligations.filter(o => {
|
||||
if (filter === 'ai') return o.source.toLowerCase().includes('ai')
|
||||
// Status/priority filter
|
||||
if (filter === 'ai') {
|
||||
if (!o.source.toLowerCase().includes('ai')) return false
|
||||
}
|
||||
// Regulation filter
|
||||
if (regulationFilter !== 'all') {
|
||||
const src = o.source?.toLowerCase() || ''
|
||||
const key = regulationFilter.toLowerCase()
|
||||
if (!src.includes(key)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -679,15 +779,38 @@ export default function ObligationsPage() {
|
||||
explanation={stepInfo?.explanation || ''}
|
||||
tips={stepInfo?.tips || []}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowModal(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" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleAutoProfiling}
|
||||
disabled={profiling}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{profiling ? 'Profiling...' : 'Auto-Profiling'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGapAnalysis(v => !v)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm ${
|
||||
showGapAnalysis ? 'bg-purple-100 text-purple-700' : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Gap-Analyse
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(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" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Error */}
|
||||
@@ -725,7 +848,28 @@ export default function ObligationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search + Filter */}
|
||||
{/* Gap Analysis View */}
|
||||
{showGapAnalysis && (
|
||||
<GapAnalysisView />
|
||||
)}
|
||||
|
||||
{/* Regulation Filter Chips */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-500 mr-1">Regulation:</span>
|
||||
{REGULATION_CHIPS.map(r => (
|
||||
<button
|
||||
key={r.key}
|
||||
onClick={() => setRegulationFilter(r.key)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
regulationFilter === r.key ? 'bg-purple-600 text-white' : 'bg-purple-50 text-purple-700 hover:bg-purple-100'
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search + Status Filter */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
@@ -735,7 +879,7 @@ export default function ObligationsPage() {
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{['all', 'overdue', 'pending', 'in-progress', 'completed', 'critical', 'ai'].map(f => (
|
||||
{['all', 'overdue', 'pending', 'in-progress', 'completed', 'critical'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
@@ -743,7 +887,7 @@ export default function ObligationsPage() {
|
||||
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' : f === 'in-progress' ? 'In Bearbeitung' : f === 'overdue' ? 'Ueberfaellig' : f === 'pending' ? 'Ausstehend' : f === 'completed' ? 'Abgeschlossen' : f === 'critical' ? 'Kritisch' : 'AI Act'}
|
||||
{f === 'all' ? 'Alle' : f === 'in-progress' ? 'In Bearbeitung' : f === 'overdue' ? 'Ueberfaellig' : f === 'pending' ? 'Ausstehend' : f === 'completed' ? 'Abgeschlossen' : 'Kritisch'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
226
admin-compliance/components/sdk/obligations/GapAnalysisView.tsx
Normal file
226
admin-compliance/components/sdk/obligations/GapAnalysisView.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface GapItem {
|
||||
control_id: string
|
||||
control_title: string
|
||||
control_domain: string
|
||||
status: string
|
||||
priority: string
|
||||
obligation_ids: string[]
|
||||
required_by_count: number
|
||||
}
|
||||
|
||||
interface PriorityAction {
|
||||
rank: number
|
||||
action: string
|
||||
control_ids: string[]
|
||||
impact: string
|
||||
effort: string
|
||||
}
|
||||
|
||||
interface DomainGap {
|
||||
domain_id: string
|
||||
domain_name: string
|
||||
total_controls: number
|
||||
implemented_controls: number
|
||||
compliance_percent: number
|
||||
}
|
||||
|
||||
interface GapAnalysisResult {
|
||||
compliance_percent: number
|
||||
total_controls: number
|
||||
implemented_controls: number
|
||||
partial_controls: number
|
||||
missing_controls: number
|
||||
gaps: GapItem[]
|
||||
priority_actions: PriorityAction[]
|
||||
by_domain: Record<string, DomainGap>
|
||||
}
|
||||
|
||||
const UCCA_API = '/api/sdk/v1/ucca/obligations'
|
||||
|
||||
const DOMAIN_LABELS: Record<string, string> = {
|
||||
GOV: 'Governance', HR: 'Human Resources', IAM: 'Identity & Access',
|
||||
AC: 'Access Control', CRYPTO: 'Kryptographie', LOG: 'Logging & Monitoring',
|
||||
SDLC: 'Softwareentwicklung', OPS: 'Betrieb', NET: 'Netzwerk',
|
||||
BCP: 'Business Continuity', VENDOR: 'Lieferanten', DATA: 'Datenschutz',
|
||||
}
|
||||
|
||||
const IMPACT_COLORS: Record<string, string> = {
|
||||
critical: 'text-red-700 bg-red-50',
|
||||
high: 'text-orange-700 bg-orange-50',
|
||||
medium: 'text-yellow-700 bg-yellow-50',
|
||||
low: 'text-green-700 bg-green-50',
|
||||
}
|
||||
|
||||
export default function GapAnalysisView() {
|
||||
const [result, setResult] = useState<GapAnalysisResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const runAnalysis = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${UCCA_API}/gap-analysis`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ control_status_map: {} }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setResult(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto bg-purple-50 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">TOM Gap-Analyse</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 mb-4">
|
||||
Vergleicht erforderliche TOM Controls mit dem aktuellen Implementierungsstatus.
|
||||
</p>
|
||||
{error && <p className="text-xs text-red-600 mb-3">{error}</p>}
|
||||
<button
|
||||
onClick={runAnalysis}
|
||||
disabled={loading}
|
||||
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Analysiere...' : 'Gap-Analyse starten'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const domains = Object.values(result.by_domain).sort((a, b) => a.compliance_percent - b.compliance_percent)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Compliance Score */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Compliance-Status</h3>
|
||||
<button
|
||||
onClick={runAnalysis}
|
||||
disabled={loading}
|
||||
className="text-xs text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`text-4xl font-bold ${result.compliance_percent >= 80 ? 'text-green-600' : result.compliance_percent >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{Math.round(result.compliance_percent)}%
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Compliance</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all ${result.compliance_percent >= 80 ? 'bg-green-500' : result.compliance_percent >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(result.compliance_percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-500">
|
||||
<span>{result.implemented_controls} implementiert</span>
|
||||
<span>{result.partial_controls} teilweise</span>
|
||||
<span>{result.missing_controls} fehlend</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain Breakdown */}
|
||||
{domains.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Nach Domaene</h4>
|
||||
<div className="space-y-2">
|
||||
{domains.map(d => (
|
||||
<div key={d.domain_id} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-600 w-32 flex-shrink-0 truncate">
|
||||
{DOMAIN_LABELS[d.domain_id] || d.domain_id}
|
||||
</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${d.compliance_percent >= 80 ? 'bg-green-400' : d.compliance_percent >= 50 ? 'bg-yellow-400' : 'bg-red-400'}`}
|
||||
style={{ width: `${Math.min(d.compliance_percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 w-14 text-right">
|
||||
{d.implemented_controls}/{d.total_controls}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority Actions */}
|
||||
{result.priority_actions.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Prioritaere Massnahmen</h4>
|
||||
<div className="space-y-2">
|
||||
{result.priority_actions.slice(0, 5).map(a => (
|
||||
<div key={a.rank} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-xs font-bold text-purple-600 bg-purple-50 rounded-full w-6 h-6 flex items-center justify-center flex-shrink-0">
|
||||
{a.rank}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900">{a.action}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${IMPACT_COLORS[a.impact] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{a.impact}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Aufwand: {a.effort}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{a.control_ids.length} Controls
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap List */}
|
||||
{result.gaps.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Offene Gaps ({result.gaps.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{result.gaps.map(g => (
|
||||
<div key={g.control_id} className="flex items-center justify-between py-2 px-3 hover:bg-gray-50 rounded text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono text-purple-600 flex-shrink-0">{g.control_id}</span>
|
||||
<span className="text-gray-700 truncate">{g.control_title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`px-1.5 py-0.5 rounded ${g.status === 'NOT_IMPLEMENTED' ? 'bg-red-50 text-red-600' : 'bg-yellow-50 text-yellow-600'}`}>
|
||||
{g.status === 'NOT_IMPLEMENTED' ? 'Fehlend' : 'Teilweise'}
|
||||
</span>
|
||||
<span className="text-gray-400">{g.required_by_count}x</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
admin-compliance/components/sdk/obligations/TOMControlPanel.tsx
Normal file
105
admin-compliance/components/sdk/obligations/TOMControlPanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface TOMControl {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
domain_id: string
|
||||
priority: string
|
||||
gdpr_articles: string[]
|
||||
}
|
||||
|
||||
interface TOMControlPanelProps {
|
||||
obligationId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const UCCA_API = '/api/sdk/v1/ucca/obligations'
|
||||
|
||||
const DOMAIN_LABELS: Record<string, string> = {
|
||||
GOV: 'Governance',
|
||||
HR: 'Human Resources',
|
||||
IAM: 'Identity & Access',
|
||||
AC: 'Access Control',
|
||||
CRYPTO: 'Kryptographie',
|
||||
LOG: 'Logging & Monitoring',
|
||||
SDLC: 'Softwareentwicklung',
|
||||
OPS: 'Betrieb',
|
||||
NET: 'Netzwerk',
|
||||
BCP: 'Business Continuity',
|
||||
VENDOR: 'Lieferanten',
|
||||
DATA: 'Datenschutz',
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
kritisch: 'bg-red-100 text-red-700',
|
||||
hoch: 'bg-orange-100 text-orange-700',
|
||||
mittel: 'bg-yellow-100 text-yellow-700',
|
||||
niedrig: 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
export default function TOMControlPanel({ obligationId, onClose }: TOMControlPanelProps) {
|
||||
const [controls, setControls] = useState<TOMControl[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${UCCA_API}/${obligationId}/tom-controls`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setControls(data.controls || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [obligationId])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-gray-50 border-b">
|
||||
<h3 className="text-sm font-semibold text-gray-900">TOM Controls</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xs">Schliessen</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{loading && <p className="text-xs text-gray-500">Lade Controls...</p>}
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
|
||||
{!loading && !error && controls.length === 0 && (
|
||||
<p className="text-xs text-gray-400">Keine TOM Controls verknuepft</p>
|
||||
)}
|
||||
|
||||
{!loading && controls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{controls.map(c => (
|
||||
<div key={c.id} className="border border-gray-100 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600">{c.id}</span>
|
||||
<span className="text-xs text-gray-400">{DOMAIN_LABELS[c.domain_id] || c.domain_id}</span>
|
||||
{c.priority && (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded ${PRIORITY_COLORS[c.priority] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{c.priority}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">{c.title}</p>
|
||||
{c.description && (
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{c.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user