Files
breakpilot-compliance/admin-compliance/app/sdk/requirements/page.tsx
Benjamin Admin 3ed8300daf
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 34s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 35s
CI / test-python-dsms-gateway (push) Successful in 17s
feat(extraction): POST /compliance/extract-requirements-from-rag
Sucht alle RAG-Kollektionen nach Prüfaspekten und legt automatisch
Anforderungen in der DB an. Kernfeatures:

- Durchsucht alle 6 RAG-Kollektionen parallel (bp_compliance_ce,
  bp_compliance_recht, bp_compliance_gesetze, bp_compliance_datenschutz,
  bp_dsfa_corpus, bp_legal_templates)
- Erkennt BSI Prüfaspekte (O.Purp_6) im Artikel-Feld und per Regex
- Dedupliziert nach (regulation_code, article) — safe to call many times
- Auto-erstellt Regulations-Stubs für unbekannte regulation_codes
- dry_run=true zeigt was erstellt würde ohne DB-Schreibzugriff
- Optionale Filter: collections, regulation_codes, search_queries
- 18 Tests (alle bestanden)
- Frontend: "Aus RAG extrahieren" Button auf /sdk/requirements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 15:11:10 +01:00

839 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import React, { useState, useEffect } from 'react'
import { useSDK, Requirement as SDKRequirement, RequirementStatus, RiskSeverity } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
type DisplayStatus = 'compliant' | 'partial' | 'non-compliant' | 'not-applicable'
interface DisplayRequirement extends SDKRequirement {
code: string
source: string
category: string
priority: DisplayPriority
displayStatus: DisplayStatus
controlsLinked: number
evidenceCount: number
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function mapCriticalityToPriority(criticality: RiskSeverity): DisplayPriority {
switch (criticality) {
case 'CRITICAL': return 'critical'
case 'HIGH': return 'high'
case 'MEDIUM': return 'medium'
case 'LOW': return 'low'
default: return 'medium'
}
}
function mapStatusToDisplayStatus(status: RequirementStatus): DisplayStatus {
switch (status) {
case 'VERIFIED':
case 'IMPLEMENTED': return 'compliant'
case 'IN_PROGRESS': return 'partial'
case 'NOT_STARTED': return 'non-compliant'
default: return 'non-compliant'
}
}
// =============================================================================
// FALLBACK TEMPLATES (used when backend is unavailable)
// =============================================================================
const requirementTemplates: Omit<DisplayRequirement, 'displayStatus' | 'controlsLinked' | 'evidenceCount'>[] = [
{
id: 'req-gdpr-6',
regulation: 'DSGVO',
article: 'Art. 6',
code: 'GDPR-6.1',
title: 'Rechtmaessigkeit der Verarbeitung',
description: 'Personenbezogene Daten duerfen nur verarbeitet werden, wenn eine Rechtsgrundlage vorliegt.',
source: 'DSGVO Art. 6',
category: 'Rechtmaessigkeit',
priority: 'critical',
criticality: 'CRITICAL',
applicableModules: ['mod-gdpr'],
status: 'NOT_STARTED',
controls: [],
},
{
id: 'req-gdpr-13',
regulation: 'DSGVO',
article: 'Art. 13/14',
code: 'GDPR-13',
title: 'Informationspflichten',
description: 'Betroffene Personen muessen ueber die Datenverarbeitung informiert werden.',
source: 'DSGVO Art. 13/14',
category: 'Transparenz',
priority: 'high',
criticality: 'HIGH',
applicableModules: ['mod-gdpr'],
status: 'NOT_STARTED',
controls: [],
},
{
id: 'req-ai-act-9',
regulation: 'AI Act',
article: 'Art. 9',
code: 'AI-ACT-9',
title: 'Risikomanagementsystem',
description: 'Hochrisiko-KI-Systeme erfordern ein Risikomanagementsystem.',
source: 'AI Act Art. 9',
category: 'KI-Governance',
priority: 'high',
criticality: 'HIGH',
applicableModules: ['mod-ai-act'],
status: 'NOT_STARTED',
controls: [],
},
{
id: 'req-gdpr-32',
regulation: 'DSGVO',
article: 'Art. 32',
code: 'GDPR-32',
title: 'Sicherheit der Verarbeitung',
description: 'Geeignete technische und organisatorische Massnahmen zur Datensicherheit.',
source: 'DSGVO Art. 32',
category: 'Sicherheit',
priority: 'critical',
criticality: 'CRITICAL',
applicableModules: ['mod-gdpr', 'mod-iso27001'],
status: 'NOT_STARTED',
controls: [],
},
{
id: 'req-gdpr-35',
regulation: 'DSGVO',
article: 'Art. 35',
code: 'GDPR-35',
title: 'Datenschutz-Folgenabschaetzung',
description: 'Bei hohem Risiko ist eine DSFA durchzufuehren.',
source: 'DSGVO Art. 35',
category: 'Risikobewertung',
priority: 'high',
criticality: 'HIGH',
applicableModules: ['mod-gdpr'],
status: 'NOT_STARTED',
controls: [],
},
{
id: 'req-ai-act-13',
regulation: 'AI Act',
article: 'Art. 13',
code: 'AI-ACT-13',
title: 'Transparenzanforderungen',
description: 'KI-Systeme muessen fuer Nutzer nachvollziehbar und transparent sein.',
source: 'AI Act Art. 13',
category: 'Transparenz',
priority: 'high',
criticality: 'HIGH',
applicableModules: ['mod-ai-act'],
status: 'NOT_STARTED',
controls: [],
},
{
id: 'req-nis2-21',
regulation: 'NIS2',
article: 'Art. 21',
code: 'NIS2-21',
title: 'Risikomanagementmassnahmen',
description: 'Wesentliche und wichtige Einrichtungen muessen Cybersicherheitsmassnahmen implementieren.',
source: 'NIS2 Art. 21',
category: 'Cybersicherheit',
priority: 'high',
criticality: 'HIGH',
applicableModules: ['mod-nis2'],
status: 'NOT_STARTED',
controls: [],
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
function AddRequirementForm({
onSubmit,
onCancel,
}: {
onSubmit: (data: { regulation: string; article: string; title: string; description: string; criticality: RiskSeverity }) => void
onCancel: () => void
}) {
const [formData, setFormData] = useState({
regulation: '',
article: '',
title: '',
description: '',
criticality: 'MEDIUM' as RiskSeverity,
})
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Neue Anforderung</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verordnung *</label>
<input
type="text"
value={formData.regulation}
onChange={e => setFormData({ ...formData, regulation: e.target.value })}
placeholder="z.B. DSGVO"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Artikel</label>
<input
type="text"
value={formData.article}
onChange={e => setFormData({ ...formData, article: e.target.value })}
placeholder="z.B. Art. 6"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Rechtmaessigkeit der Verarbeitung"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreiben Sie die Anforderung..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kritikalitaet</label>
<select
value={formData.criticality}
onChange={e => setFormData({ ...formData, criticality: e.target.value as RiskSeverity })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="LOW">Niedrig</option>
<option value="MEDIUM">Mittel</option>
<option value="HIGH">Hoch</option>
<option value="CRITICAL">Kritisch</option>
</select>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title || !formData.regulation}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title && formData.regulation ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Hinzufuegen
</button>
</div>
</div>
)
}
function RequirementCard({
requirement,
onStatusChange,
onDelete,
expanded,
onToggleDetails,
linkedControls,
}: {
requirement: DisplayRequirement
onStatusChange: (status: RequirementStatus) => void
onDelete: () => void
expanded: boolean
onToggleDetails: () => void
linkedControls: { id: string; name: string }[]
}) {
const priorityColors = {
critical: 'bg-red-100 text-red-700',
high: 'bg-orange-100 text-orange-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-green-100 text-green-700',
}
const statusColors = {
compliant: 'bg-green-100 text-green-700 border-green-200',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
'non-compliant': 'bg-red-100 text-red-700 border-red-200',
'not-applicable': 'bg-gray-100 text-gray-500 border-gray-200',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[requirement.displayStatus]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">
{requirement.code}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${priorityColors[requirement.priority]}`}>
{requirement.priority === 'critical' ? 'Kritisch' :
requirement.priority === 'high' ? 'Hoch' :
requirement.priority === 'medium' ? 'Mittel' : 'Niedrig'}
</span>
<span className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
{requirement.regulation}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{requirement.title}</h3>
<p className="text-sm text-gray-500 mt-1">{requirement.description}</p>
<p className="text-xs text-gray-400 mt-2">Quelle: {requirement.source}</p>
</div>
<select
value={requirement.status}
onChange={(e) => onStatusChange(e.target.value as RequirementStatus)}
className={`px-3 py-1 text-sm rounded-full border ${statusColors[requirement.displayStatus]}`}
>
<option value="NOT_STARTED">Nicht begonnen</option>
<option value="IN_PROGRESS">In Bearbeitung</option>
<option value="IMPLEMENTED">Implementiert</option>
<option value="VERIFIED">Verifiziert</option>
</select>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>{requirement.controlsLinked} Kontrollen</span>
<span>{requirement.evidenceCount} Nachweise</span>
</div>
<button
onClick={onToggleDetails}
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
{expanded ? 'Details ausblenden' : 'Details anzeigen'}
</button>
</div>
{expanded && (
<div className="mt-4 pt-4 border-t border-gray-200 space-y-3">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-1">Vollstaendige Beschreibung</h4>
<p className="text-sm text-gray-600">{requirement.description || 'Keine Beschreibung vorhanden.'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-700 mb-1">Zugeordnete Kontrollen ({linkedControls.length})</h4>
{linkedControls.length > 0 ? (
<div className="flex flex-wrap gap-2">
{linkedControls.map(c => (
<span key={c.id} className="px-2 py-1 text-xs bg-green-50 text-green-700 rounded">{c.name}</span>
))}
</div>
) : (
<p className="text-sm text-gray-400">Keine Kontrollen zugeordnet</p>
)}
</div>
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-1">Status-Historie</h4>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span className={`px-2 py-0.5 text-xs rounded-full ${
requirement.displayStatus === 'compliant' ? 'bg-green-100 text-green-700' :
requirement.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{requirement.status === 'NOT_STARTED' ? 'Nicht begonnen' :
requirement.status === 'IN_PROGRESS' ? 'In Bearbeitung' :
requirement.status === 'IMPLEMENTED' ? 'Implementiert' : 'Verifiziert'}
</span>
</div>
</div>
<button
onClick={onDelete}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
)}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="h-5 w-20 bg-gray-200 rounded" />
<div className="h-5 w-16 bg-gray-200 rounded-full" />
</div>
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded" />
</div>
))}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function RequirementsPage() {
const { state, dispatch } = useSDK()
const [filter, setFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [ragExtracting, setRagExtracting] = useState(false)
const [ragResult, setRagResult] = useState<{ created: number; skipped_duplicates: number; message: string } | null>(null)
const extractFromRAG = async () => {
setRagExtracting(true)
setRagResult(null)
try {
const res = await fetch('/api/sdk/v1/compliance/extract-requirements-from-rag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_per_query: 20 }),
})
if (res.ok) {
const data = await res.json()
setRagResult({ created: data.created, skipped_duplicates: data.skipped_duplicates, message: data.message })
// Reload requirements list
const listRes = await fetch('/api/sdk/v1/compliance/requirements')
if (listRes.ok) {
const listData = await listRes.json()
const reqs = listData.requirements || listData
if (Array.isArray(reqs) && reqs.length > 0) {
const mapped = reqs.map((r: Record<string, unknown>) => ({
id: (r.requirement_id || r.id) as string,
regulation: (r.regulation_code || r.regulation || '') as string,
article: (r.article || '') as string,
title: (r.title || '') as string,
description: (r.description || '') as string,
criticality: ((r.criticality || r.priority || 'MEDIUM') as string).toUpperCase() as import('@/lib/sdk').RiskSeverity,
applicableModules: [] as string[],
status: 'NOT_STARTED' as import('@/lib/sdk').RequirementStatus,
controls: [] as string[],
}))
dispatch({ type: 'SET_STATE', payload: { requirements: mapped } })
}
}
} else {
setRagResult({ created: 0, skipped_duplicates: 0, message: 'RAG-Extraktion fehlgeschlagen' })
}
} catch {
setRagResult({ created: 0, skipped_duplicates: 0, message: 'RAG-Extraktion nicht erreichbar' })
} finally {
setRagExtracting(false)
}
}
// Fetch requirements from backend on mount
useEffect(() => {
const fetchRequirements = async () => {
try {
setLoading(true)
const res = await fetch('/api/sdk/v1/compliance/requirements')
if (res.ok) {
const data = await res.json()
const backendRequirements = data.requirements || data
if (Array.isArray(backendRequirements) && backendRequirements.length > 0) {
// Map backend data to SDK format and load into state
const mapped: SDKRequirement[] = backendRequirements.map((r: Record<string, unknown>) => ({
id: (r.requirement_id || r.id) as string,
regulation: (r.regulation_code || r.regulation || '') as string,
article: (r.article || '') as string,
title: (r.title || '') as string,
description: (r.description || '') as string,
criticality: ((r.criticality || r.priority || 'MEDIUM') as string).toUpperCase() as RiskSeverity,
applicableModules: (r.applicable_modules || []) as string[],
status: (r.status || 'NOT_STARTED') as RequirementStatus,
controls: (r.controls || []) as string[],
}))
dispatch({ type: 'SET_STATE', payload: { requirements: mapped } })
setError(null)
return
}
}
// If backend returns empty or fails, fall back to templates
loadFromTemplates()
} catch {
// Backend unavailable — use templates
loadFromTemplates()
} finally {
setLoading(false)
}
}
const loadFromTemplates = () => {
if (state.requirements.length > 0) return // Already have data
if (state.modules.length === 0) return // No modules yet
const activeModuleIds = state.modules.map(m => m.id)
const relevantRequirements = requirementTemplates.filter(r =>
r.applicableModules.some(m => activeModuleIds.includes(m))
)
relevantRequirements.forEach(req => {
const sdkRequirement: SDKRequirement = {
id: req.id,
regulation: req.regulation,
article: req.article,
title: req.title,
description: req.description,
criticality: req.criticality,
applicableModules: req.applicableModules,
status: 'NOT_STARTED',
controls: [],
}
dispatch({ type: 'ADD_REQUIREMENT', payload: sdkRequirement })
})
}
fetchRequirements()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Convert SDK requirements to display requirements
const displayRequirements: DisplayRequirement[] = state.requirements.map(req => {
const template = requirementTemplates.find(t => t.id === req.id)
const linkedControls = state.controls.filter(c => c.evidence.includes(req.id))
const linkedEvidence = state.evidence.filter(e => e.controlId && linkedControls.some(c => c.id === e.controlId))
return {
...req,
code: template?.code || req.id,
source: template?.source || `${req.regulation} ${req.article}`,
category: template?.category || req.regulation,
priority: mapCriticalityToPriority(req.criticality),
displayStatus: mapStatusToDisplayStatus(req.status),
controlsLinked: linkedControls.length,
evidenceCount: linkedEvidence.length,
}
})
const filteredRequirements = displayRequirements.filter(req => {
const matchesFilter = filter === 'all' ||
req.displayStatus === filter ||
req.priority === filter
const matchesSearch = searchQuery === '' ||
req.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
req.code.toLowerCase().includes(searchQuery.toLowerCase())
return matchesFilter && matchesSearch
})
const compliantCount = displayRequirements.filter(r => r.displayStatus === 'compliant').length
const partialCount = displayRequirements.filter(r => r.displayStatus === 'partial').length
const nonCompliantCount = displayRequirements.filter(r => r.displayStatus === 'non-compliant').length
const handleStatusChange = async (requirementId: string, status: RequirementStatus) => {
const previousStatus = state.requirements.find(r => r.id === requirementId)?.status
dispatch({
type: 'UPDATE_REQUIREMENT',
payload: { id: requirementId, data: { status } },
})
// Persist to backend
try {
const res = await fetch(`/api/sdk/v1/compliance/requirements/${requirementId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ implementation_status: status.toLowerCase() }),
})
if (!res.ok) {
// Rollback on failure
if (previousStatus) {
dispatch({ type: 'UPDATE_REQUIREMENT', payload: { id: requirementId, data: { status: previousStatus } } })
}
setError('Status-Aenderung konnte nicht gespeichert werden')
}
} catch {
if (previousStatus) {
dispatch({ type: 'UPDATE_REQUIREMENT', payload: { id: requirementId, data: { status: previousStatus } } })
}
setError('Backend nicht erreichbar — Aenderung zurueckgesetzt')
}
}
const handleDeleteRequirement = async (requirementId: string) => {
if (!confirm('Anforderung wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/compliance/requirements/${requirementId}`, {
method: 'DELETE',
})
if (res.ok) {
dispatch({ type: 'SET_STATE', payload: { requirements: state.requirements.filter(r => r.id !== requirementId) } })
} else {
setError('Loeschen fehlgeschlagen')
}
} catch {
setError('Backend nicht erreichbar')
}
}
const handleAddRequirement = async (data: { regulation: string; article: string; title: string; description: string; criticality: RiskSeverity }) => {
// Try to resolve regulation_id from backend
let regulationId = ''
try {
const regRes = await fetch(`/api/sdk/v1/compliance/regulations/${data.regulation}`)
if (regRes.ok) {
const regData = await regRes.json()
regulationId = regData.id
}
} catch {
// Regulation not found — still add locally
}
const priorityMap: Record<string, number> = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 }
if (regulationId) {
try {
const res = await fetch('/api/sdk/v1/compliance/requirements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
regulation_id: regulationId,
article: data.article,
title: data.title,
description: data.description,
priority: priorityMap[data.criticality] || 2,
}),
})
if (res.ok) {
const created = await res.json()
const newReq: SDKRequirement = {
id: created.id,
regulation: data.regulation,
article: data.article,
title: data.title,
description: data.description,
criticality: data.criticality,
applicableModules: [],
status: 'NOT_STARTED',
controls: [],
}
dispatch({ type: 'ADD_REQUIREMENT', payload: newReq })
setShowAddForm(false)
return
}
} catch {
// Fall through to local-only add
}
}
// Fallback: add locally only
const newReq: SDKRequirement = {
id: `req-${Date.now()}`,
regulation: data.regulation,
article: data.article,
title: data.title,
description: data.description,
criticality: data.criticality,
applicableModules: [],
status: 'NOT_STARTED',
controls: [],
}
dispatch({ type: 'ADD_REQUIREMENT', payload: newReq })
setShowAddForm(false)
}
const stepInfo = STEP_EXPLANATIONS['requirements']
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="requirements"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<div className="flex items-center gap-2">
<button
onClick={extractFromRAG}
disabled={ragExtracting}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-60 transition-colors"
>
{ragExtracting ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.347.347a3.5 3.5 0 01-4.95 0l-.347-.347z" />
</svg>
)}
Aus RAG extrahieren
</button>
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Anforderung hinzufuegen
</button>
</div>
</StepHeader>
{/* RAG Extraction Result Banner */}
{ragResult && (
<div className={`flex items-center justify-between p-3 rounded-lg border ${ragResult.created > 0 ? 'bg-green-50 border-green-200' : 'bg-blue-50 border-blue-200'}`}>
<span className="text-sm">
{ragResult.created > 0 ? '✅' : ''} {ragResult.message}
</span>
<button onClick={() => setRagResult(null)} className="text-gray-400 hover:text-gray-600 ml-4">&times;</button>
</div>
)}
{/* Add Form */}
{showAddForm && (
<AddRequirementForm
onSubmit={handleAddRequirement}
onCancel={() => setShowAddForm(false)}
/>
)}
{/* Error Banner */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">&times;</button>
</div>
)}
{/* Module Alert */}
{state.modules.length === 0 && !loading && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-amber-600 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>
<h4 className="font-medium text-amber-800">Keine Module aktiviert</h4>
<p className="text-sm text-amber-700 mt-1">
Bitte aktivieren Sie zuerst Compliance-Module, um die zugehoerigen Anforderungen zu laden.
</p>
</div>
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{displayRequirements.length}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Konform</div>
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-6">
<div className="text-sm text-yellow-600">In Bearbeitung</div>
<div className="text-3xl font-bold text-yellow-600">{partialCount}</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6">
<div className="text-sm text-red-600">Offen</div>
<div className="text-3xl font-bold text-red-600">{nonCompliantCount}</div>
</div>
</div>
{/* Search and Filter */}
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<svg className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" 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>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Anforderungen durchsuchen..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-2">
{['all', 'compliant', 'partial', 'non-compliant', 'critical'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filter === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' :
f === 'compliant' ? 'Konform' :
f === 'partial' ? 'Teilweise' :
f === 'non-compliant' ? 'Offen' : 'Kritisch'}
</button>
))}
</div>
</div>
{/* Loading State */}
{loading && <LoadingSkeleton />}
{/* Requirements List */}
{!loading && (
<div className="space-y-4">
{filteredRequirements.map(requirement => {
const linkedControls = state.controls
.filter(c => c.evidence.includes(requirement.id))
.map(c => ({ id: c.id, name: c.name }))
return (
<RequirementCard
key={requirement.id}
requirement={requirement}
onStatusChange={(status) => handleStatusChange(requirement.id, status)}
onDelete={() => handleDeleteRequirement(requirement.id)}
expanded={expandedId === requirement.id}
onToggleDetails={() => setExpandedId(expandedId === requirement.id ? null : requirement.id)}
linkedControls={linkedControls}
/>
)
})}
</div>
)}
{!loading && filteredRequirements.length === 0 && state.modules.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Anforderungen gefunden</h3>
<p className="mt-2 text-gray-500">Passen Sie die Suche oder den Filter an.</p>
</div>
)}
</div>
)
}