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

- 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:
Benjamin Admin
2026-03-05 14:51:44 +01:00
parent 2540a2189a
commit 38e278ee3c
32 changed files with 22870 additions and 41 deletions

View File

@@ -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') }

View File

@@ -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>

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

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