merge: phases 1–5 refactor, CI hardening, docs (coolify → main)
Some checks failed
Build + Deploy / build-admin-compliance (push) Failing after 47s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 34s
Build + Deploy / build-developer-portal (push) Successful in 56s
Build + Deploy / build-tts (push) Successful in 26s
Build + Deploy / build-document-crawler (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / loc-budget (push) Successful in 22s
CI/CD / guardrail-integrity (push) Has been skipped
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been cancelled
CI/CD / test-go-ai-compliance (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Successful in 28s
CI/CD / sbom-scan (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Successful in 20s
Some checks failed
Build + Deploy / build-admin-compliance (push) Failing after 47s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 34s
Build + Deploy / build-developer-portal (push) Successful in 56s
Build + Deploy / build-tts (push) Successful in 26s
Build + Deploy / build-document-crawler (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / loc-budget (push) Successful in 22s
CI/CD / guardrail-integrity (push) Has been skipped
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been cancelled
CI/CD / test-go-ai-compliance (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Successful in 28s
CI/CD / sbom-scan (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Successful in 20s
Phase 1: backend-compliance — partial service-layer extraction Phase 2: ai-compliance-sdk — full hexagonal split; iace/ucca/training handlers and stores split into focused files; cmd/server/main.go → internal/app/ Phase 3: admin-compliance — types.ts, tom-generator loader, and major page components split; lib document generators extracted Phase 4: dsms-gateway, consent-sdk, developer-portal, breakpilot-compliance-sdk Phase 5 CI hardening: - loc-budget job now scans whole repo (blocking, no || true) - sbom-scan / grype blocking on high+ CVEs - ai-compliance-sdk/.golangci.yml: strict golangci-lint config - check-loc.sh: skip test_*.py and *.html; loc-exceptions.txt expanded - deleted stray routes.py.backup (2512 LOC) Docs: - root README.md with CI badge, service table, quick start, CI pipeline table - CONTRIBUTING.md: setup, pre-commit checklist, guardrail marker reference - CLAUDE.md: First-Time Setup & Claude Code Onboarding section - all 7 service READMEs updated (stale phase refs, current architecture) - AGENTS.go/python/typescript.md enhanced with linting, DI, barrel re-export - .gitignore: dist/, .turbo/, pnpm-lock.yaml added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { bg: string; label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch', icon: AlertTriangle },
|
||||
high: { bg: 'bg-orange-100 text-orange-800', label: 'Hoch', icon: AlertTriangle },
|
||||
medium: { bg: 'bg-yellow-100 text-yellow-800', label: 'Mittel', icon: Info },
|
||||
low: { bg: 'bg-green-100 text-green-800', label: 'Niedrig', icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.medium
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StateBadge({ state }: { state: string }) {
|
||||
const config: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
deprecated: 'bg-red-100 text-red-600',
|
||||
needs_review: 'bg-yellow-100 text-yellow-800',
|
||||
too_close: 'bg-red-100 text-red-700',
|
||||
duplicate: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
needs_review: 'Review noetig',
|
||||
too_close: 'Zu aehnlich',
|
||||
duplicate: 'Duplikat',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config[state] || config.draft}`}>
|
||||
{labels[state] || state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||
if (!rule) return null
|
||||
const config: Record<number, { bg: string; label: string }> = {
|
||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
||||
}
|
||||
const c = config[rule]
|
||||
if (!c) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, ArrowLeft, ExternalLink, CheckCircle2, Lock,
|
||||
FileText, BookOpen, Scale, Pencil, Trash2, Eye, Clock,
|
||||
} from 'lucide-react'
|
||||
import type { CanonicalControl } from '../_types'
|
||||
import { EFFORT_LABELS } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
export function ControlDetailView({
|
||||
ctrl,
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReview,
|
||||
}: {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
onEdit: () => void
|
||||
onDelete: (controlId: string) => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button onClick={onBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="w-4 h-4" /> Zurueck zur Uebersicht
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onEdit} className="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50">
|
||||
<Pencil className="w-3.5 h-3.5" /> Bearbeiten
|
||||
</button>
|
||||
<button onClick={() => onDelete(ctrl.control_id)} className="flex items-center gap-1 px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
|
||||
<Trash2 className="w-3.5 h-3.5" /> Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-mono text-purple-600">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{ctrl.title}</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <span>Risiko-Score: {ctrl.risk_score}/10</span>}
|
||||
{ctrl.implementation_effort && <span>Aufwand: {EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.objective}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.rationale}</p>
|
||||
</section>
|
||||
|
||||
{/* Scope */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.platforms.map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.components.map(c => (
|
||||
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.data_classes.map(d => (
|
||||
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Requirements */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.requirements.map((req, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium mt-0.5">{i + 1}</span>
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.test_procedure.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Evidence */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
|
||||
<p className="text-sm text-gray-700">{ev.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open Anchors — THE KEY SECTION */}
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
|
||||
<span className="text-xs text-green-600">({ctrl.open_anchors.length} Quellen)</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mb-3">
|
||||
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-2 bg-white rounded border border-green-100">
|
||||
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-semibold text-green-800">{anchor.framework}</span>
|
||||
<p className="text-sm text-gray-700">{anchor.ref}</p>
|
||||
</div>
|
||||
<a
|
||||
href={anchor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Quelle
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctrl.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* License & Citation Info */}
|
||||
{ctrl.license_rule && (
|
||||
<section className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Scale className="w-4 h-4 text-blue-700" />
|
||||
<h3 className="text-sm font-semibold text-blue-900">Lizenzinformationen</h3>
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
</div>
|
||||
{ctrl.source_citation && (
|
||||
<div className="text-xs text-blue-800 space-y-1">
|
||||
<p><span className="font-medium">Quelle:</span> {ctrl.source_citation.source}</p>
|
||||
{ctrl.source_citation.license && <p><span className="font-medium">Lizenz:</span> {ctrl.source_citation.license}</p>}
|
||||
{ctrl.source_citation.license_notice && <p><span className="font-medium">Hinweis:</span> {ctrl.source_citation.license_notice}</p>}
|
||||
{ctrl.source_citation.url && (
|
||||
<a href={ctrl.source_citation.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-blue-600 hover:text-blue-800">
|
||||
<ExternalLink className="w-3 h-3" /> Originalquelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="mt-1 text-xs text-gray-700 bg-white rounded p-2 border border-blue-100 max-h-40 overflow-y-auto">{ctrl.source_original_text}</p>
|
||||
</details>
|
||||
)}
|
||||
{ctrl.license_rule === 3 && (
|
||||
<p className="text-xs text-amber-700 mt-2 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
Eigenstaendig formuliert — keine Originalquelle gespeichert
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Generation Metadata (internal) */}
|
||||
{ctrl.generation_metadata && (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path || '-')}</p>
|
||||
{ctrl.generation_metadata.similarity_status && (
|
||||
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
|
||||
)}
|
||||
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
|
||||
<div>
|
||||
<p className="font-medium">Aehnliche Controls:</p>
|
||||
{(ctrl.generation_metadata.similar_controls as Array<Record<string, unknown>>).map((s, i) => (
|
||||
<p key={i} className="ml-2">{String(s.control_id)} — {String(s.title)} ({String(s.similarity)})</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Review Actions */}
|
||||
{['needs_review', 'too_close', 'duplicate'].includes(ctrl.release_state) && (
|
||||
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-yellow-700" />
|
||||
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'approve')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Akzeptieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'reject')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ueberarbeiten
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, Trash2, Save, X } from 'lucide-react'
|
||||
import type { ControlFormData } from '../_types'
|
||||
|
||||
export function ControlForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
initial: ControlFormData
|
||||
onSave: (data: ControlFormData) => void
|
||||
onCancel: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [form, setForm] = useState(initial)
|
||||
const [tagInput, setTagInput] = useState(initial.tags.join(', '))
|
||||
const [platformInput, setPlatformInput] = useState((initial.scope.platforms || []).join(', '))
|
||||
const [componentInput, setComponentInput] = useState((initial.scope.components || []).join(', '))
|
||||
const [dataClassInput, setDataClassInput] = useState((initial.scope.data_classes || []).join(', '))
|
||||
|
||||
const handleSave = () => {
|
||||
const data = {
|
||||
...form,
|
||||
tags: tagInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
scope: {
|
||||
platforms: platformInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
components: componentInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
data_classes: dataClassInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
},
|
||||
requirements: form.requirements.filter(r => r.trim()),
|
||||
test_procedure: form.test_procedure.filter(r => r.trim()),
|
||||
evidence: form.evidence.filter(e => e.type.trim() || e.description.trim()),
|
||||
open_anchors: form.open_anchors.filter(a => a.framework.trim() || a.ref.trim()),
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{initial.control_id ? `Control ${initial.control_id} bearbeiten` : 'Neues Control erstellen'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<X className="w-4 h-4 inline mr-1" />Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<Save className="w-4 h-4 inline mr-1" />{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Control-ID *</label>
|
||||
<input
|
||||
value={form.control_id}
|
||||
onChange={e => setForm({ ...form, control_id: e.target.value.toUpperCase() })}
|
||||
placeholder="AUTH-003"
|
||||
disabled={!!initial.control_id}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Format: DOMAIN-NNN (z.B. AUTH-003, NET-005)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Titel *</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Control-Titel"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Schweregrad</label>
|
||||
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Risiko-Score (0-10)</label>
|
||||
<input
|
||||
type="number" min="0" max="10" step="0.5"
|
||||
value={form.risk_score ?? ''}
|
||||
onChange={e => setForm({ ...form, risk_score: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Aufwand</label>
|
||||
<select value={form.implementation_effort || ''} onChange={e => setForm({ ...form, implementation_effort: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">-</option>
|
||||
<option value="s">Klein (S)</option>
|
||||
<option value="m">Mittel (M)</option>
|
||||
<option value="l">Gross (L)</option>
|
||||
<option value="xl">Sehr gross (XL)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Ziel *</label>
|
||||
<textarea
|
||||
value={form.objective}
|
||||
onChange={e => setForm({ ...form, objective: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Begruendung *</label>
|
||||
<textarea
|
||||
value={form.rationale}
|
||||
onChange={e => setForm({ ...form, rationale: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Plattformen (komma-getrennt)</label>
|
||||
<input value={platformInput} onChange={e => setPlatformInput(e.target.value)} placeholder="web, mobile, api" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Komponenten (komma-getrennt)</label>
|
||||
<input value={componentInput} onChange={e => setComponentInput(e.target.value)} placeholder="auth-service, gateway" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datenklassen (komma-getrennt)</label>
|
||||
<input value={dataClassInput} onChange={e => setDataClassInput(e.target.value)} placeholder="credentials, tokens" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Anforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, requirements: [...form.requirements, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.requirements.map((req, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={req}
|
||||
onChange={e => { const r = [...form.requirements]; r[i] = e.target.value; setForm({ ...form, requirements: r }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, requirements: form.requirements.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Pruefverfahren</label>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: [...form.test_procedure, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.test_procedure.map((step, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={step}
|
||||
onChange={e => { const t = [...form.test_procedure]; t[i] = e.target.value; setForm({ ...form, test_procedure: t }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: form.test_procedure.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Evidence */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Nachweisanforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, evidence: [...form.evidence, { type: '', description: '' }] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={ev.type}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], type: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Typ (z.B. config, test_result)"
|
||||
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
value={ev.description}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], description: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Beschreibung"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, evidence: form.evidence.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<label className="text-xs font-semibold text-green-900">Open-Source-Referenzen *</label>
|
||||
</div>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: [...form.open_anchors, { framework: '', ref: '', url: '' }] })} className="text-xs text-green-700 hover:text-green-900">+ Hinzufuegen</button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 mb-3">Jedes Control braucht mindestens eine offene Referenz (OWASP, NIST, ENISA, etc.)</p>
|
||||
{form.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={anchor.framework}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], framework: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Framework (z.B. OWASP ASVS)"
|
||||
className="w-40 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.ref}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], ref: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Referenz (z.B. V2.8)"
|
||||
className="w-48 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.url}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], url: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="https://..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: form.open_anchors.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tags & State */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Tags (komma-getrennt)</label>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} placeholder="mfa, auth, iam" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||
<select value={form.release_state} onChange={e => setForm({ ...form, release_state: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, Search, ChevronRight, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3,
|
||||
} from 'lucide-react'
|
||||
import type { CanonicalControl, Framework } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
export function ControlListView({
|
||||
controls,
|
||||
filteredControls,
|
||||
frameworks,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
severityFilter,
|
||||
setSeverityFilter,
|
||||
domainFilter,
|
||||
setDomainFilter,
|
||||
stateFilter,
|
||||
setStateFilter,
|
||||
domains,
|
||||
showStats,
|
||||
toggleStats,
|
||||
processedStats,
|
||||
onOpenGenerator,
|
||||
onCreate,
|
||||
onSelect,
|
||||
}: {
|
||||
controls: CanonicalControl[]
|
||||
filteredControls: CanonicalControl[]
|
||||
frameworks: Framework[]
|
||||
searchQuery: string
|
||||
setSearchQuery: (v: string) => void
|
||||
severityFilter: string
|
||||
setSeverityFilter: (v: string) => void
|
||||
domainFilter: string
|
||||
setDomainFilter: (v: string) => void
|
||||
stateFilter: string
|
||||
setStateFilter: (v: string) => void
|
||||
domains: string[]
|
||||
showStats: boolean
|
||||
toggleStats: () => void
|
||||
processedStats: Array<Record<string, unknown>>
|
||||
onOpenGenerator: () => void
|
||||
onCreate: () => void
|
||||
onSelect: (ctrl: CanonicalControl) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
{controls.length} unabhaengig formulierte Security Controls —{' '}
|
||||
{controls.reduce((sum, c) => sum + c.open_anchors.length, 0)} Open-Source-Referenzen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleStats}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenGenerator}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Generator
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Neues Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frameworks */}
|
||||
{frameworks.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-700">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
|
||||
<span className="text-purple-500">—</span>
|
||||
<span>{frameworks[0]?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Controls durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Schweregrade</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Domains</option>
|
||||
{domains.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={e => setStateFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="needs_review">Review noetig</option>
|
||||
<option value="too_close">Zu aehnlich</option>
|
||||
<option value="duplicate">Duplikat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Processing Stats */}
|
||||
{showStats && processedStats.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-2">Verarbeitungsfortschritt</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{processedStats.map((s, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<span className="font-medium text-gray-700">{String(s.collection)}</span>
|
||||
<div className="flex gap-2 mt-1 text-gray-500">
|
||||
<span>{String(s.processed_chunks)} verarbeitet</span>
|
||||
<span>{String(s.direct_adopted)} direkt</span>
|
||||
<span>{String(s.llm_reformed)} reformuliert</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => onSelect(ctrl)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
{ctrl.risk_score !== null && (
|
||||
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
|
||||
{/* Open anchors summary */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<BookOpen className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-700">
|
||||
{ctrl.open_anchors.length} Open-Source-Referenzen:
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{ctrl.open_anchors.map(a => a.framework).filter((v, i, arr) => arr.indexOf(v) === i).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filteredControls.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
{controls.length === 0
|
||||
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
|
||||
: 'Keine Controls gefunden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { Zap, X, RefreshCw } from 'lucide-react'
|
||||
|
||||
export function GeneratorModal({
|
||||
genDomain,
|
||||
setGenDomain,
|
||||
genMaxControls,
|
||||
setGenMaxControls,
|
||||
genDryRun,
|
||||
setGenDryRun,
|
||||
generating,
|
||||
genResult,
|
||||
onGenerate,
|
||||
onClose,
|
||||
}: {
|
||||
genDomain: string
|
||||
setGenDomain: (v: string) => void
|
||||
genMaxControls: number
|
||||
setGenMaxControls: (v: number) => void
|
||||
genDryRun: boolean
|
||||
setGenDryRun: (v: boolean) => void
|
||||
generating: boolean
|
||||
genResult: Record<string, unknown> | null
|
||||
onGenerate: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Control Generator</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Domain (optional)</label>
|
||||
<select value={genDomain} onChange={e => setGenDomain(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">Alle Domains</option>
|
||||
<option value="AUTH">AUTH — Authentifizierung</option>
|
||||
<option value="CRYPT">CRYPT — Kryptographie</option>
|
||||
<option value="NET">NET — Netzwerk</option>
|
||||
<option value="DATA">DATA — Datenschutz</option>
|
||||
<option value="LOG">LOG — Logging</option>
|
||||
<option value="ACC">ACC — Zugriffskontrolle</option>
|
||||
<option value="SEC">SEC — Sicherheit</option>
|
||||
<option value="INC">INC — Incident Response</option>
|
||||
<option value="AI">AI — Kuenstliche Intelligenz</option>
|
||||
<option value="COMP">COMP — Compliance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Max. Controls: {genMaxControls}</label>
|
||||
<input
|
||||
type="range" min="1" max="100" step="1"
|
||||
value={genMaxControls}
|
||||
onChange={e => setGenMaxControls(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dryRun"
|
||||
checked={genDryRun}
|
||||
onChange={e => setGenDryRun(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="dryRun" className="text-sm text-gray-700">Dry Run (Vorschau ohne Speicherung)</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={generating}
|
||||
className="w-full py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{generating ? (
|
||||
<><RefreshCw className="w-4 h-4 animate-spin" /> Generiere...</>
|
||||
) : (
|
||||
<><Zap className="w-4 h-4" /> Generierung starten</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{genResult && (
|
||||
<div className={`p-4 rounded-lg text-sm ${genResult.status === 'error' ? 'bg-red-50 text-red-800' : 'bg-green-50 text-green-800'}`}>
|
||||
<p className="font-medium mb-1">{String(genResult.message || genResult.status)}</p>
|
||||
{genResult.status !== 'error' && (
|
||||
<div className="grid grid-cols-2 gap-1 text-xs mt-2">
|
||||
<span>Chunks gescannt: {String(genResult.total_chunks_scanned)}</span>
|
||||
<span>Controls generiert: {String(genResult.controls_generated)}</span>
|
||||
<span>Verifiziert: {String(genResult.controls_verified)}</span>
|
||||
<span>Review noetig: {String(genResult.controls_needs_review)}</span>
|
||||
<span>Zu aehnlich: {String(genResult.controls_too_close)}</span>
|
||||
<span>Duplikate: {String(genResult.controls_duplicates_found)}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(genResult.errors) && (genResult.errors as string[]).length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{(genResult.errors as string[]).slice(0, 3).map((e, i) => <p key={i}>{e}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
admin-compliance/app/sdk/control-library/_types.ts
Normal file
87
admin-compliance/app/sdk/control-library/_types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface OpenAnchor {
|
||||
framework: string
|
||||
ref: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
type: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CanonicalControl {
|
||||
id: string
|
||||
framework_id: string
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
scope: {
|
||||
platforms?: string[]
|
||||
components?: string[]
|
||||
data_classes?: string[]
|
||||
}
|
||||
requirements: string[]
|
||||
test_procedure: string[]
|
||||
evidence: EvidenceItem[]
|
||||
severity: string
|
||||
risk_score: number | null
|
||||
implementation_effort: string | null
|
||||
evidence_confidence: number | null
|
||||
open_anchors: OpenAnchor[]
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule?: number | null
|
||||
source_original_text?: string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
customer_visible?: boolean
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
id: string
|
||||
framework_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
export const EFFORT_LABELS: Record<string, string> = {
|
||||
s: 'Klein (S)',
|
||||
m: 'Mittel (M)',
|
||||
l: 'Gross (L)',
|
||||
xl: 'Sehr gross (XL)',
|
||||
}
|
||||
|
||||
export const BACKEND_URL = '/api/sdk/v1/canonical'
|
||||
|
||||
export const EMPTY_CONTROL = {
|
||||
framework_id: 'bp_security_v1',
|
||||
control_id: '',
|
||||
title: '',
|
||||
objective: '',
|
||||
rationale: '',
|
||||
scope: { platforms: [] as string[], components: [] as string[], data_classes: [] as string[] },
|
||||
requirements: [''],
|
||||
test_procedure: [''],
|
||||
evidence: [{ type: '', description: '' }],
|
||||
severity: 'medium',
|
||||
risk_score: null as number | null,
|
||||
implementation_effort: 'm' as string | null,
|
||||
open_anchors: [{ framework: '', ref: '', url: '' }],
|
||||
release_state: 'draft',
|
||||
tags: [] as string[],
|
||||
}
|
||||
|
||||
export type ControlFormData = typeof EMPTY_CONTROL
|
||||
|
||||
export function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
@@ -2,81 +2,48 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
ArrowLeft, ExternalLink, BookOpen, Scale, FileText,
|
||||
Eye, CheckCircle2, Trash2, Pencil, Clock,
|
||||
ChevronLeft, SkipForward, GitMerge, Search, Landmark,
|
||||
ArrowLeft, BookOpen, ExternalLink, FileText, Eye,
|
||||
Clock, ChevronLeft, SkipForward, Pencil, Trash2, Scale, GitMerge,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||
ObligationTypeBadge, GenerationStrategyBadge, isEigenentwicklung,
|
||||
ExtractionMethodBadge, RegulationCountBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge,
|
||||
EvidenceTypeBadge, TargetAudienceBadge, GenerationStrategyBadge, ObligationTypeBadge,
|
||||
isEigenentwicklung,
|
||||
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
||||
} from './helpers'
|
||||
import { ControlSourceCitation } from './ControlSourceCitation'
|
||||
import { ControlTraceability } from './ControlTraceability'
|
||||
import { ControlRegulatorySection } from './ControlRegulatorySection'
|
||||
import { ControlSimilarControls } from './ControlSimilarControls'
|
||||
import { ControlReviewActions } from './ControlReviewActions'
|
||||
|
||||
interface SimilarControl {
|
||||
control_id: string
|
||||
title: string
|
||||
severity: string
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule: number | null
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
similarity: number
|
||||
control_id: string; title: string; severity: string; release_state: string;
|
||||
tags: string[]; license_rule: number | null; verification_method: string | null;
|
||||
category: string | null; similarity: number;
|
||||
}
|
||||
|
||||
interface ParentLink {
|
||||
parent_control_id: string
|
||||
parent_title: string
|
||||
link_type: string
|
||||
confidence: number
|
||||
source_regulation: string | null
|
||||
source_article: string | null
|
||||
parent_citation: Record<string, string> | null
|
||||
obligation: {
|
||||
text: string
|
||||
action: string
|
||||
object: string
|
||||
normative_strength: string
|
||||
} | null
|
||||
parent_control_id: string; parent_title: string; link_type: string; confidence: number;
|
||||
source_regulation: string | null; source_article: string | null;
|
||||
parent_citation: Record<string, string> | null;
|
||||
obligation: { text: string; action: string; object: string; normative_strength: string } | null;
|
||||
}
|
||||
|
||||
interface TraceabilityData {
|
||||
control_id: string
|
||||
title: string
|
||||
is_atomic: boolean
|
||||
parent_links: ParentLink[]
|
||||
children: Array<{
|
||||
control_id: string
|
||||
title: string
|
||||
category: string
|
||||
severity: string
|
||||
decomposition_method: string
|
||||
}>
|
||||
source_count: number
|
||||
// Extended provenance fields
|
||||
obligations?: ObligationInfo[]
|
||||
obligation_count?: number
|
||||
document_references?: DocumentReference[]
|
||||
merged_duplicates?: MergedDuplicate[]
|
||||
merged_duplicates_count?: number
|
||||
regulations_summary?: RegulationSummary[]
|
||||
control_id: string; title: string; is_atomic: boolean; parent_links: ParentLink[];
|
||||
children: Array<{ control_id: string; title: string; category: string; severity: string; decomposition_method: string }>;
|
||||
source_count: number; obligations?: ObligationInfo[]; obligation_count?: number;
|
||||
document_references?: DocumentReference[]; merged_duplicates?: MergedDuplicate[];
|
||||
merged_duplicates_count?: number; regulations_summary?: RegulationSummary[];
|
||||
}
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
matched_control_id: string; matched_title: string; matched_objective: string;
|
||||
matched_severity: string; matched_category: string; matched_source: string | null;
|
||||
matched_article: string | null; matched_source_citation: Record<string, string> | null;
|
||||
similarity_score: number; match_rank: number; match_method: string;
|
||||
}
|
||||
|
||||
interface ControlDetailProps {
|
||||
@@ -88,7 +55,6 @@ interface ControlDetailProps {
|
||||
onRefresh?: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
||||
// Review mode navigation
|
||||
reviewMode?: boolean
|
||||
reviewIndex?: number
|
||||
reviewTotal?: number
|
||||
@@ -97,19 +63,8 @@ interface ControlDetailProps {
|
||||
}
|
||||
|
||||
export function ControlDetail({
|
||||
ctrl,
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReview,
|
||||
onRefresh,
|
||||
onNavigateToControl,
|
||||
onCompare,
|
||||
reviewMode,
|
||||
reviewIndex = 0,
|
||||
reviewTotal = 0,
|
||||
onReviewPrev,
|
||||
onReviewNext,
|
||||
ctrl, onBack, onEdit, onDelete, onReview, onRefresh, onNavigateToControl, onCompare,
|
||||
reviewMode, reviewIndex = 0, reviewTotal = 0, onReviewPrev, onReviewNext,
|
||||
}: ControlDetailProps) {
|
||||
const [similarControls, setSimilarControls] = useState<SimilarControl[]>([])
|
||||
const [loadingSimilar, setLoadingSimilar] = useState(false)
|
||||
@@ -124,14 +79,9 @@ export function ControlDetail({
|
||||
const loadTraceability = useCallback(async () => {
|
||||
setLoadingTrace(true)
|
||||
try {
|
||||
// Try provenance first (extended data), fall back to traceability
|
||||
let res = await fetch(`${BACKEND_URL}?endpoint=provenance&id=${ctrl.control_id}`)
|
||||
if (!res.ok) {
|
||||
res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
||||
}
|
||||
if (res.ok) {
|
||||
setTraceability(await res.json())
|
||||
}
|
||||
if (!res.ok) res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
||||
if (res.ok) setTraceability(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingTrace(false) }
|
||||
}, [ctrl.control_id])
|
||||
@@ -141,63 +91,45 @@ export function ControlDetail({
|
||||
setLoadingV1(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=v1-matches&id=${ctrl.control_id}`)
|
||||
if (res.ok) setV1Matches(await res.json())
|
||||
else setV1Matches([])
|
||||
if (res.ok) setV1Matches(await res.json()); else setV1Matches([])
|
||||
} catch { setV1Matches([]) }
|
||||
finally { setLoadingV1(false) }
|
||||
}, [ctrl.control_id, eigenentwicklung])
|
||||
|
||||
useEffect(() => {
|
||||
loadSimilarControls()
|
||||
loadTraceability()
|
||||
loadV1Matches()
|
||||
setSelectedDuplicates(new Set())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctrl.control_id])
|
||||
|
||||
const loadSimilarControls = async () => {
|
||||
setLoadingSimilar(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=similar&id=${ctrl.control_id}`)
|
||||
if (res.ok) {
|
||||
setSimilarControls(await res.json())
|
||||
}
|
||||
if (res.ok) setSimilarControls(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingSimilar(false) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSimilarControls(); loadTraceability(); loadV1Matches()
|
||||
setSelectedDuplicates(new Set())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctrl.control_id])
|
||||
|
||||
const toggleDuplicate = (controlId: string) => {
|
||||
setSelectedDuplicates(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(controlId)) next.delete(controlId)
|
||||
else next.add(controlId)
|
||||
return next
|
||||
})
|
||||
setSelectedDuplicates(prev => { const n = new Set(prev); if (n.has(controlId)) n.delete(controlId); else n.add(controlId); return n })
|
||||
}
|
||||
|
||||
const handleMergeDuplicates = async () => {
|
||||
if (selectedDuplicates.size === 0) return
|
||||
if (!confirm(`${selectedDuplicates.size} Controls als Duplikate markieren und Tags/Anchors in ${ctrl.control_id} zusammenfuehren?`)) return
|
||||
|
||||
setMerging(true)
|
||||
try {
|
||||
// For each duplicate: mark as deprecated
|
||||
for (const dupId of selectedDuplicates) {
|
||||
await fetch(`${BACKEND_URL}?endpoint=update-control&id=${dupId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ release_state: 'deprecated' }),
|
||||
})
|
||||
}
|
||||
// Refresh to show updated state
|
||||
if (onRefresh) onRefresh()
|
||||
setSelectedDuplicates(new Set())
|
||||
loadSimilarControls()
|
||||
} catch {
|
||||
alert('Fehler beim Zusammenfuehren')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
setSelectedDuplicates(new Set()); loadSimilarControls()
|
||||
} catch { alert('Fehler beim Zusammenfuehren') }
|
||||
finally { setMerging(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -205,9 +137,7 @@ export function ControlDetail({
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600"><ArrowLeft className="w-5 h-5" /></button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
@@ -227,13 +157,9 @@ export function ControlDetail({
|
||||
<div className="flex items-center gap-2">
|
||||
{reviewMode && (
|
||||
<div className="flex items-center gap-1 mr-3">
|
||||
<button onClick={onReviewPrev} disabled={reviewIndex === 0} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onReviewPrev} disabled={reviewIndex === 0} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"><ChevronLeft className="w-4 h-4" /></button>
|
||||
<span className="text-xs text-gray-500 font-medium">{reviewIndex + 1} / {reviewTotal}</span>
|
||||
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"><SkipForward className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onEdit} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
@@ -247,392 +173,19 @@ export function ControlDetail({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 max-w-4xl mx-auto w-full space-y-6">
|
||||
{/* Objective */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.objective}</p>
|
||||
</section>
|
||||
<section><h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3><p className="text-sm text-gray-700 leading-relaxed">{ctrl.objective}</p></section>
|
||||
<section><h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3><p className="text-sm text-gray-700 leading-relaxed">{ctrl.rationale}</p></section>
|
||||
|
||||
{/* Rationale */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.rationale}</p>
|
||||
</section>
|
||||
<ControlSourceCitation ctrl={ctrl} />
|
||||
|
||||
{/* Quellennachweis (Rule 1 + 2) — dynamic label based on source_type */}
|
||||
{ctrl.source_citation && (
|
||||
<section className={`border rounded-lg p-4 ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'bg-blue-50 border-blue-200' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'bg-indigo-50 border-indigo-200' :
|
||||
'bg-teal-50 border-teal-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className={`w-4 h-4 ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'text-blue-600' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-600' :
|
||||
'text-teal-600'
|
||||
}`} />
|
||||
<h3 className={`text-sm font-semibold ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'text-blue-900' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-900' :
|
||||
'text-teal-900'
|
||||
}`}>{
|
||||
ctrl.source_citation.source_type === 'law' ? 'Gesetzliche Grundlage' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'Behoerdliche Leitlinie' :
|
||||
'Standard / Best Practice'
|
||||
}</h3>
|
||||
{ctrl.source_citation.source_type === 'law' && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">Direkte gesetzliche Pflicht</span>
|
||||
)}
|
||||
{ctrl.source_citation.source_type === 'guideline' && (
|
||||
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">Aufsichtsbehoerdliche Empfehlung</span>
|
||||
)}
|
||||
{(ctrl.source_citation.source_type === 'standard' || (!ctrl.source_citation.source_type && ctrl.license_rule === 2)) && (
|
||||
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded-full">Freiwilliger Standard</span>
|
||||
)}
|
||||
{(!ctrl.source_citation.source_type && ctrl.license_rule === 1) && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">Noch nicht klassifiziert</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
{ctrl.source_citation.source ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</p>
|
||||
) : ctrl.generation_metadata?.source_regulation ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">{String(ctrl.generation_metadata.source_regulation)}</p>
|
||||
) : null}
|
||||
{ctrl.source_citation.license && (
|
||||
<p className="text-xs text-blue-600">Lizenz: {ctrl.source_citation.license}</p>
|
||||
)}
|
||||
{ctrl.source_citation.license_notice && (
|
||||
<p className="text-xs text-blue-600 mt-0.5">{ctrl.source_citation.license_notice}</p>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.source_citation.url && (
|
||||
<a
|
||||
href={ctrl.source_citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 whitespace-nowrap"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />Quelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="text-xs text-gray-600 mt-2 p-2 bg-white rounded border border-blue-100 leading-relaxed max-h-40 overflow-y-auto whitespace-pre-wrap">
|
||||
{ctrl.source_original_text}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Regulatorische Abdeckung (Eigenentwicklung) */}
|
||||
{eigenentwicklung && (
|
||||
<section className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-orange-600" />
|
||||
<h3 className="text-sm font-semibold text-orange-900">
|
||||
Regulatorische Abdeckung
|
||||
</h3>
|
||||
{loadingV1 && <span className="text-xs text-orange-400">Laden...</span>}
|
||||
</div>
|
||||
{v1Matches.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{v1Matches.map((match, i) => (
|
||||
<div key={i} className="bg-white/60 border border-orange-100 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
{match.matched_source && (
|
||||
<span className="text-xs font-semibold text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">
|
||||
{match.matched_source}
|
||||
</span>
|
||||
)}
|
||||
{match.matched_article && (
|
||||
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">
|
||||
{match.matched_article}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||
match.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||
match.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{(match.similarity_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(match.matched_control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline mr-1.5"
|
||||
>
|
||||
{match.matched_control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mr-1.5">
|
||||
{match.matched_control_id}
|
||||
</span>
|
||||
)}
|
||||
{match.matched_title}
|
||||
</p>
|
||||
</div>
|
||||
{onCompare && (
|
||||
<button
|
||||
onClick={() => onCompare(ctrl, v1Matches)}
|
||||
className="text-xs text-orange-600 border border-orange-300 rounded px-2 py-1 hover:bg-orange-100 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
Vergleichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !loadingV1 ? (
|
||||
<p className="text-sm text-orange-600">Keine regulatorische Abdeckung gefunden. Dieses Control ist eine reine Eigenentwicklung.</p>
|
||||
) : null}
|
||||
</section>
|
||||
<ControlRegulatorySection ctrl={ctrl} v1Matches={v1Matches} loadingV1={loadingV1}
|
||||
onNavigateToControl={onNavigateToControl} onCompare={onCompare} />
|
||||
)}
|
||||
|
||||
{/* Rechtsgrundlagen / Traceability (atomic controls) */}
|
||||
{traceability && traceability.parent_links.length > 0 && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Landmark className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">
|
||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
||||
</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{traceability.regulations_summary && traceability.regulations_summary.map(rs => (
|
||||
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
|
||||
{rs.regulation_code}
|
||||
</span>
|
||||
))}
|
||||
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{traceability.parent_links.map((link, i) => (
|
||||
<div key={i} className="bg-white/60 border border-violet-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Scale className="w-4 h-4 text-violet-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{link.source_regulation && (
|
||||
<span className="text-sm font-semibold text-violet-900">{link.source_regulation}</span>
|
||||
)}
|
||||
{link.source_article && (
|
||||
<span className="text-sm text-violet-700">{link.source_article}</span>
|
||||
)}
|
||||
{!link.source_regulation && link.parent_citation?.source && (
|
||||
<span className="text-sm font-semibold text-violet-900">
|
||||
{link.parent_citation.source}
|
||||
{link.parent_citation.article && ` — ${link.parent_citation.article}`}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
link.link_type === 'decomposition' ? 'bg-violet-100 text-violet-600' :
|
||||
link.link_type === 'dedup_merge' ? 'bg-blue-100 text-blue-600' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{link.link_type === 'decomposition' ? 'Ableitung' :
|
||||
link.link_type === 'dedup_merge' ? 'Dedup' :
|
||||
link.link_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-violet-600 mt-1">
|
||||
via{' '}
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(link.parent_control_id)}
|
||||
className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{link.parent_control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded">
|
||||
{link.parent_control_id}
|
||||
</span>
|
||||
)}
|
||||
{link.parent_title && (
|
||||
<span className="text-violet-500 ml-1">— {link.parent_title}</span>
|
||||
)}
|
||||
</p>
|
||||
{link.obligation && (
|
||||
<p className="text-xs text-violet-500 mt-1.5 bg-violet-50 rounded p-2">
|
||||
<span className={`inline-block mr-1.5 px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
link.obligation.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
link.obligation.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{link.obligation.normative_strength === 'must' ? 'MUSS' :
|
||||
link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{link.obligation.text.slice(0, 200)}
|
||||
{link.obligation.text.length > 200 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<ControlTraceability ctrl={ctrl} traceability={traceability} loadingTrace={loadingTrace}
|
||||
onNavigateToControl={onNavigateToControl} />
|
||||
|
||||
{/* Fallback: simple parent display when traceability not loaded yet */}
|
||||
{ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<GitMerge className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">Atomares Control</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<p className="text-sm text-violet-800">
|
||||
Abgeleitet aus Eltern-Control{' '}
|
||||
<span className="font-mono font-semibold text-purple-700 bg-purple-100 px-1.5 py-0.5 rounded">
|
||||
{ctrl.parent_control_id || ctrl.parent_control_uuid}
|
||||
</span>
|
||||
{ctrl.parent_control_title && (
|
||||
<span className="text-violet-700 ml-1">— {ctrl.parent_control_title}</span>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Document References (atomic controls) */}
|
||||
{traceability && traceability.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
|
||||
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-indigo-600" />
|
||||
<h3 className="text-sm font-semibold text-indigo-900">
|
||||
Original-Dokumente ({traceability.document_references.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.document_references.map((dr, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
|
||||
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
|
||||
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
|
||||
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<ExtractionMethodBadge method={dr.extraction_method} />
|
||||
{dr.confidence !== null && (
|
||||
<span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Obligations (rich controls) */}
|
||||
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.obligations.map((ob) => (
|
||||
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{ob.normative_strength === 'must' ? 'MUSS' :
|
||||
ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
|
||||
{ob.object && <span className="text-xs text-amber-500">→ {ob.object}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
{ob.obligation_text.slice(0, 300)}
|
||||
{ob.obligation_text.length > 300 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Merged Duplicates */}
|
||||
{traceability && traceability.merged_duplicates && traceability.merged_duplicates.length > 0 && (
|
||||
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.merged_duplicates.map((dup) => (
|
||||
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(dup.control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{dup.control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{dup.control_id}</span>
|
||||
)}
|
||||
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
|
||||
{dup.source_regulation && (
|
||||
<span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Child controls (rich controls that have atomic children) */}
|
||||
{traceability && traceability.children.length > 0 && (
|
||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-emerald-900">
|
||||
Abgeleitete Controls ({traceability.children.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.children.map((child) => (
|
||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(child.control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{child.control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{child.control_id}</span>
|
||||
)}
|
||||
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
||||
<SeverityBadge severity={child.severity} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Impliziter Gesetzesbezug (Rule 3 — reformuliert, kein Originaltext) */}
|
||||
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -648,49 +201,31 @@ export function ControlDetail({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Scope */}
|
||||
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
{ctrl.scope.platforms?.length ? (
|
||||
<div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div>
|
||||
) : null}
|
||||
{ctrl.scope.components?.length ? (
|
||||
<div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div>
|
||||
) : null}
|
||||
{ctrl.scope.data_classes?.length ? (
|
||||
<div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div>
|
||||
) : null}
|
||||
{ctrl.scope.platforms?.length ? <div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div> : null}
|
||||
{ctrl.scope.components?.length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div> : null}
|
||||
{ctrl.scope.data_classes?.length ? <div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Requirements */}
|
||||
{ctrl.requirements.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{ctrl.requirements.map((r, i) => (
|
||||
<li key={i} className="text-sm text-gray-700">{r}</li>
|
||||
))}
|
||||
</ol>
|
||||
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Test Procedure */}
|
||||
{ctrl.test_procedure.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{ctrl.test_procedure.map((s, i) => (
|
||||
<li key={i} className="text-sm text-gray-700">{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Evidence — handles both {type, description} objects and plain strings */}
|
||||
{ctrl.evidence.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
||||
@@ -698,31 +233,23 @@ export function ControlDetail({
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
{typeof ev === 'string' ? (
|
||||
<div>{ev}</div>
|
||||
) : (
|
||||
<div><span className="font-medium">{ev.type}:</span> {ev.description}</div>
|
||||
)}
|
||||
{typeof ev === 'string' ? <div>{ev}</div> : <div><span className="font-medium">{ev.type}:</span> {ev.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<section className="grid grid-cols-3 gap-4 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <div>Risiko-Score: <span className="text-gray-700 font-medium">{ctrl.risk_score}</span></div>}
|
||||
{ctrl.implementation_effort && <div>Aufwand: <span className="text-gray-700 font-medium">{EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span></div>}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<div className="col-span-3 flex items-center gap-1 flex-wrap">
|
||||
{ctrl.tags.map(t => (
|
||||
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
|
||||
))}
|
||||
{ctrl.tags.map(t => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
@@ -735,11 +262,7 @@ export function ControlDetail({
|
||||
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
<span className="font-medium text-green-800">{anchor.framework}</span>
|
||||
<span className="text-green-700">{anchor.ref}</span>
|
||||
{anchor.url && (
|
||||
<a href={anchor.url} target="_blank" rel="noopener noreferrer" className="text-green-600 hover:text-green-800 underline text-xs ml-auto">
|
||||
Link
|
||||
</a>
|
||||
)}
|
||||
{anchor.url && <a href={anchor.url} target="_blank" rel="noopener noreferrer" className="text-green-600 hover:text-green-800 underline text-xs ml-auto">Link</a>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -748,7 +271,6 @@ export function ControlDetail({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Generation Metadata (internal) */}
|
||||
{ctrl.generation_metadata && (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -756,21 +278,11 @@ export function ControlDetail({
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
{ctrl.generation_metadata.processing_path && (
|
||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.decomposition_method && (
|
||||
<p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.pass0b_model && (
|
||||
<p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.obligation_type && (
|
||||
<p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.similarity_status && (
|
||||
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.processing_path && <p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>}
|
||||
{ctrl.generation_metadata.decomposition_method && <p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>}
|
||||
{ctrl.generation_metadata.pass0b_model && <p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>}
|
||||
{ctrl.generation_metadata.obligation_type && <p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>}
|
||||
{ctrl.generation_metadata.similarity_status && <p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>}
|
||||
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
|
||||
<div>
|
||||
<p className="font-medium">Aehnliche Controls:</p>
|
||||
@@ -783,95 +295,16 @@ export function ControlDetail({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Similar Controls (Dedup) */}
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Search className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Aehnliche Controls</h3>
|
||||
{loadingSimilar && <span className="text-xs text-gray-400">Laden...</span>}
|
||||
</div>
|
||||
<ControlSimilarControls
|
||||
ctrl={ctrl} similarControls={similarControls} loadingSimilar={loadingSimilar}
|
||||
selectedDuplicates={selectedDuplicates} merging={merging}
|
||||
onToggleDuplicate={toggleDuplicate} onMergeDuplicates={handleMergeDuplicates}
|
||||
/>
|
||||
|
||||
{similarControls.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-3 p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input type="radio" checked readOnly className="text-purple-600" />
|
||||
<span className="text-sm font-medium text-purple-700">{ctrl.control_id} — {ctrl.title}</span>
|
||||
<span className="text-xs text-gray-400 ml-auto">Behalten (Haupt-Control)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarControls.map(sim => (
|
||||
<div key={sim.control_id} className="p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDuplicates.has(sim.control_id)}
|
||||
onChange={() => toggleDuplicate(sim.control_id)}
|
||||
className="text-red-600"
|
||||
/>
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{sim.control_id}</span>
|
||||
<span className="text-sm text-gray-700 flex-1">{sim.title}</span>
|
||||
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
|
||||
{(sim.similarity * 100).toFixed(1)}%
|
||||
</span>
|
||||
<LicenseRuleBadge rule={sim.license_rule} />
|
||||
<VerificationMethodBadge method={sim.verification_method} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedDuplicates.size > 0 && (
|
||||
<button
|
||||
onClick={handleMergeDuplicates}
|
||||
disabled={merging}
|
||||
className="mt-3 flex items-center gap-1.5 px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5" />
|
||||
{merging ? 'Zusammenfuehren...' : `${selectedDuplicates.size} Duplikat(e) zusammenfuehren`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
{loadingSimilar ? 'Suche aehnliche Controls...' : 'Keine aehnlichen Controls gefunden.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Review Actions */}
|
||||
{['needs_review', 'too_close', 'duplicate'].includes(ctrl.release_state) && (
|
||||
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-yellow-700" />
|
||||
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
||||
{reviewMode && (
|
||||
<span className="text-xs text-yellow-600 ml-auto">Review-Modus aktiv</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'approve')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Akzeptieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'reject')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ueberarbeiten
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<ControlReviewActions
|
||||
controlId={ctrl.control_id} releaseState={ctrl.release_state}
|
||||
reviewMode={reviewMode} onReview={onReview} onEdit={onEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, Search, ChevronRight, ChevronLeft, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3, ListChecks, Trash2,
|
||||
ChevronsLeft, ChevronsRight, ArrowUpDown, Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, Framework,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge, ObligationTypeBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||
} from './helpers'
|
||||
import { ControlsMeta } from './useControlLibraryState'
|
||||
import { GeneratorModal } from './GeneratorModal'
|
||||
|
||||
interface ControlListViewProps {
|
||||
frameworks: Framework[]
|
||||
controls: CanonicalControl[]
|
||||
totalCount: number
|
||||
meta: ControlsMeta | null
|
||||
loading: boolean
|
||||
reviewCount: number
|
||||
bulkProcessing: boolean
|
||||
showStats: boolean
|
||||
processedStats: Array<Record<string, unknown>>
|
||||
showGenerator: boolean
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
sortBy: 'id' | 'newest' | 'oldest' | 'source'
|
||||
// Filter values
|
||||
searchQuery: string
|
||||
severityFilter: string
|
||||
domainFilter: string
|
||||
stateFilter: string
|
||||
verificationFilter: string
|
||||
categoryFilter: string
|
||||
evidenceTypeFilter: string
|
||||
audienceFilter: string
|
||||
sourceFilter: string
|
||||
typeFilter: string
|
||||
hideDuplicates: boolean
|
||||
// Setters
|
||||
setSearchQuery: (v: string) => void
|
||||
setSeverityFilter: (v: string) => void
|
||||
setDomainFilter: (v: string) => void
|
||||
setStateFilter: (v: string) => void
|
||||
setVerificationFilter: (v: string) => void
|
||||
setCategoryFilter: (v: string) => void
|
||||
setEvidenceTypeFilter: (v: string) => void
|
||||
setAudienceFilter: (v: string) => void
|
||||
setSourceFilter: (v: string) => void
|
||||
setTypeFilter: (v: string) => void
|
||||
setHideDuplicates: (v: boolean) => void
|
||||
setSortBy: (v: 'id' | 'newest' | 'oldest' | 'source') => void
|
||||
setShowStats: (v: boolean) => void
|
||||
setShowGenerator: (v: boolean) => void
|
||||
setCurrentPage: (v: number | ((p: number) => number)) => void
|
||||
// Actions
|
||||
onSelectControl: (c: CanonicalControl) => void
|
||||
onCreateMode: () => void
|
||||
onEnterReview: () => void
|
||||
onBulkReject: (state: string) => void
|
||||
onRefresh: () => void
|
||||
onLoadStats: () => void
|
||||
onFullReload: () => void
|
||||
}
|
||||
|
||||
export function ControlListView({
|
||||
frameworks, controls, totalCount, meta, loading,
|
||||
reviewCount, bulkProcessing, showStats, processedStats,
|
||||
showGenerator, currentPage, totalPages, sortBy,
|
||||
searchQuery, severityFilter, domainFilter, stateFilter,
|
||||
verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter,
|
||||
sourceFilter, typeFilter, hideDuplicates,
|
||||
setSearchQuery, setSeverityFilter, setDomainFilter, setStateFilter,
|
||||
setVerificationFilter, setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter,
|
||||
setSourceFilter, setTypeFilter, setHideDuplicates, setSortBy,
|
||||
setShowStats, setShowGenerator, setCurrentPage,
|
||||
onSelectControl, onCreateMode, onEnterReview, onBulkReject, onRefresh, onLoadStats, onFullReload,
|
||||
}: ControlListViewProps) {
|
||||
const debouncedSearch = searchQuery // used for empty state message
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
|
||||
<p className="text-xs text-gray-500">{meta?.total ?? totalCount} Security Controls</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{reviewCount > 0 && (
|
||||
<>
|
||||
<button onClick={onEnterReview} className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Review ({reviewCount})
|
||||
</button>
|
||||
<button onClick={() => onBulkReject('needs_review')} disabled={bulkProcessing}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{bulkProcessing ? 'Wird verarbeitet...' : `Alle ${reviewCount} ablehnen`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => { setShowStats(!showStats); if (!showStats) onLoadStats() }}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<BarChart3 className="w-4 h-4" />Stats
|
||||
</button>
|
||||
<button onClick={() => setShowGenerator(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700">
|
||||
<Zap className="w-4 h-4" />Generator
|
||||
</button>
|
||||
<button onClick={onCreateMode}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<Plus className="w-4 h-4" />Neues Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{frameworks.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-700">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
|
||||
<span className="text-purple-500">—</span>
|
||||
<span>{frameworks[0]?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input type="text" placeholder="Controls durchsuchen (ID, Titel, Objective)..."
|
||||
value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500" />
|
||||
</div>
|
||||
<button onClick={onRefresh} className="p-2 text-gray-400 hover:text-purple-600" title="Aktualisieren">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select value={severityFilter} onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch{meta?.severity_counts?.critical ? ` (${meta.severity_counts.critical})` : ''}</option>
|
||||
<option value="high">Hoch{meta?.severity_counts?.high ? ` (${meta.severity_counts.high})` : ''}</option>
|
||||
<option value="medium">Mittel{meta?.severity_counts?.medium ? ` (${meta.severity_counts.medium})` : ''}</option>
|
||||
<option value="low">Niedrig{meta?.severity_counts?.low ? ` (${meta.severity_counts.low})` : ''}</option>
|
||||
</select>
|
||||
<select value={domainFilter} onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Domain</option>
|
||||
{(meta?.domains || []).map(d => <option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>)}
|
||||
</select>
|
||||
<select value={stateFilter} onChange={e => setStateFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Status</option>
|
||||
<option value="draft">Draft{meta?.release_state_counts?.draft ? ` (${meta.release_state_counts.draft})` : ''}</option>
|
||||
<option value="approved">Approved{meta?.release_state_counts?.approved ? ` (${meta.release_state_counts.approved})` : ''}</option>
|
||||
<option value="needs_review">Review noetig{meta?.release_state_counts?.needs_review ? ` (${meta.release_state_counts.needs_review})` : ''}</option>
|
||||
<option value="too_close">Zu aehnlich{meta?.release_state_counts?.too_close ? ` (${meta.release_state_counts.too_close})` : ''}</option>
|
||||
<option value="duplicate">Duplikat{meta?.release_state_counts?.duplicate ? ` (${meta.release_state_counts.duplicate})` : ''}</option>
|
||||
<option value="deprecated">Deprecated{meta?.release_state_counts?.deprecated ? ` (${meta.release_state_counts.deprecated})` : ''}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap">
|
||||
<input type="checkbox" checked={hideDuplicates} onChange={e => setHideDuplicates(e.target.checked)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
Duplikate ausblenden
|
||||
</label>
|
||||
<select value={verificationFilter} onChange={e => setVerificationFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Nachweis</option>
|
||||
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
|
||||
))}
|
||||
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={categoryFilter} onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Kategorie</option>
|
||||
{CATEGORY_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>)}
|
||||
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={evidenceTypeFilter} onChange={e => setEvidenceTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Nachweisart</option>
|
||||
{EVIDENCE_TYPE_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option>)}
|
||||
{meta?.evidence_type_counts?.['__none__'] ? <option value="__none__">Ohne Nachweisart ({meta.evidence_type_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={audienceFilter} onChange={e => setAudienceFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Zielgruppe</option>
|
||||
<option value="unternehmen">Unternehmen</option>
|
||||
<option value="behoerden">Behoerden</option>
|
||||
<option value="entwickler">Entwickler</option>
|
||||
<option value="datenschutzbeauftragte">DSB</option>
|
||||
<option value="geschaeftsfuehrung">Geschaeftsfuehrung</option>
|
||||
<option value="it-abteilung">IT-Abteilung</option>
|
||||
<option value="rechtsabteilung">Rechtsabteilung</option>
|
||||
<option value="compliance-officer">Compliance Officer</option>
|
||||
<option value="personalwesen">Personalwesen</option>
|
||||
<option value="einkauf">Einkauf</option>
|
||||
<option value="produktion">Produktion</option>
|
||||
<option value="vertrieb">Vertrieb</option>
|
||||
<option value="gesundheitswesen">Gesundheitswesen</option>
|
||||
<option value="finanzwesen">Finanzwesen</option>
|
||||
<option value="oeffentlicher_dienst">Oeffentl. Dienst</option>
|
||||
</select>
|
||||
<select value={sourceFilter} onChange={e => setSourceFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500 max-w-[220px]">
|
||||
<option value="">Dokumentenursprung</option>
|
||||
{meta && <option value="__none__">Ohne Quelle ({meta.no_source_count})</option>}
|
||||
{(meta?.sources || []).map(s => <option key={s.source} value={s.source}>{s.source} ({s.count})</option>)}
|
||||
</select>
|
||||
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="rich">Rich Controls{meta?.type_counts ? ` (${meta.type_counts.rich})` : ''}</option>
|
||||
<option value="atomic">Atomare Controls{meta?.type_counts ? ` (${meta.type_counts.atomic})` : ''}</option>
|
||||
<option value="eigenentwicklung">Eigenentwicklung{meta?.type_counts ? ` (${meta.type_counts.eigenentwicklung})` : ''}</option>
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest' | 'source')}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="id">Sortierung: ID</option>
|
||||
<option value="source">Nach Quelle</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Aelteste zuerst</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showStats && processedStats.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-2">Verarbeitungsfortschritt</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{processedStats.map((s, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<span className="font-medium text-gray-700">{String(s.collection)}</span>
|
||||
<div className="flex gap-2 mt-1 text-gray-500">
|
||||
<span>{String(s.processed_chunks)} verarbeitet</span>
|
||||
<span>{String(s.direct_adopted)} direkt</span>
|
||||
<span>{String(s.llm_reformed)} reformuliert</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showGenerator && <GeneratorModal onClose={() => setShowGenerator(false)} onComplete={onFullReload} />}
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`}
|
||||
{loading && <span className="ml-2 text-purple-500">Lade...</span>}
|
||||
</span>
|
||||
{stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && (
|
||||
<button onClick={() => onBulkReject(stateFilter)} disabled={bulkProcessing}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-white bg-red-600 rounded hover:bg-red-700 disabled:opacity-50">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{bulkProcessing ? '...' : `Alle ${totalCount} ablehnen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{controls.map((ctrl, idx) => {
|
||||
const prevSource = idx > 0 ? (controls[idx - 1].source_citation?.source || 'Ohne Quelle') : null
|
||||
const curSource = ctrl.source_citation?.source || 'Ohne Quelle'
|
||||
const showSourceHeader = sortBy === 'source' && curSource !== prevSource
|
||||
return (
|
||||
<div key={ctrl.control_id}>
|
||||
{showSourceHeader && (
|
||||
<div className="flex items-center gap-2 pt-3 pb-1">
|
||||
<div className="h-px flex-1 bg-blue-200" />
|
||||
<span className="text-xs font-semibold text-blue-700 bg-blue-50 px-2 py-0.5 rounded whitespace-nowrap">{curSource}</span>
|
||||
<div className="h-px flex-1 bg-blue-200" />
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => onSelectControl(ctrl)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{ctrl.risk_score !== null && <span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<BookOpen className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-700">{ctrl.open_anchors.length} Referenzen</span>
|
||||
{ctrl.source_citation?.source && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="text-xs text-blue-600">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-300">|</span>
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
||||
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '–'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{controls.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
{totalCount === 0 && !debouncedSearch && !severityFilter && !domainFilter
|
||||
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
|
||||
: 'Keine Controls gefunden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Erste Seite">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Vorherige Seite">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||
acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === 'dots' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||
) : (
|
||||
<button key={p} onClick={() => setCurrentPage(p as number)}
|
||||
className={`w-8 h-8 text-sm rounded-lg ${currentPage === p ? 'bg-purple-600 text-white' : 'text-gray-600 hover:bg-purple-50 hover:text-purple-600'}`}>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Naechste Seite">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Letzte Seite">
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { Scale } from 'lucide-react'
|
||||
import type { CanonicalControl } from './helpers'
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface ControlRegulatorySectionProps {
|
||||
ctrl: CanonicalControl
|
||||
v1Matches: V1Match[]
|
||||
loadingV1: boolean
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
||||
}
|
||||
|
||||
export function ControlRegulatorySection({
|
||||
ctrl, v1Matches, loadingV1, onNavigateToControl, onCompare,
|
||||
}: ControlRegulatorySectionProps) {
|
||||
return (
|
||||
<section className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-orange-600" />
|
||||
<h3 className="text-sm font-semibold text-orange-900">Regulatorische Abdeckung</h3>
|
||||
{loadingV1 && <span className="text-xs text-orange-400">Laden...</span>}
|
||||
</div>
|
||||
{v1Matches.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{v1Matches.map((match, i) => (
|
||||
<div key={i} className="bg-white/60 border border-orange-100 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
{match.matched_source && (
|
||||
<span className="text-xs font-semibold text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">{match.matched_source}</span>
|
||||
)}
|
||||
{match.matched_article && (
|
||||
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">{match.matched_article}</span>
|
||||
)}
|
||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||
match.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||
match.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{(match.similarity_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800">
|
||||
{onNavigateToControl ? (
|
||||
<button onClick={() => onNavigateToControl(match.matched_control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline mr-1.5">
|
||||
{match.matched_control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mr-1.5">{match.matched_control_id}</span>
|
||||
)}
|
||||
{match.matched_title}
|
||||
</p>
|
||||
</div>
|
||||
{onCompare && (
|
||||
<button onClick={() => onCompare(ctrl, v1Matches)}
|
||||
className="text-xs text-orange-600 border border-orange-300 rounded px-2 py-1 hover:bg-orange-100 whitespace-nowrap flex-shrink-0">
|
||||
Vergleichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !loadingV1 ? (
|
||||
<p className="text-sm text-orange-600">Keine regulatorische Abdeckung gefunden. Dieses Control ist eine reine Eigenentwicklung.</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { Eye, CheckCircle2, Trash2, Pencil } from 'lucide-react'
|
||||
|
||||
interface ControlReviewActionsProps {
|
||||
controlId: string
|
||||
releaseState: string
|
||||
reviewMode?: boolean
|
||||
onReview: (controlId: string, action: string) => void
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
export function ControlReviewActions({
|
||||
controlId, releaseState, reviewMode, onReview, onEdit,
|
||||
}: ControlReviewActionsProps) {
|
||||
if (!['needs_review', 'too_close', 'duplicate'].includes(releaseState)) return null
|
||||
|
||||
return (
|
||||
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-yellow-700" />
|
||||
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
||||
{reviewMode && <span className="text-xs text-yellow-600 ml-auto">Review-Modus aktiv</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onReview(controlId, 'approve')} className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />Akzeptieren
|
||||
</button>
|
||||
<button onClick={() => onReview(controlId, 'reject')} className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />Ablehnen
|
||||
</button>
|
||||
<button onClick={onEdit} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />Ueberarbeiten
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { Search, GitMerge } from 'lucide-react'
|
||||
import {
|
||||
LicenseRuleBadge, VerificationMethodBadge, type CanonicalControl,
|
||||
} from './helpers'
|
||||
|
||||
interface SimilarControl {
|
||||
control_id: string
|
||||
title: string
|
||||
severity: string
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule: number | null
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface ControlSimilarControlsProps {
|
||||
ctrl: CanonicalControl
|
||||
similarControls: SimilarControl[]
|
||||
loadingSimilar: boolean
|
||||
selectedDuplicates: Set<string>
|
||||
merging: boolean
|
||||
onToggleDuplicate: (id: string) => void
|
||||
onMergeDuplicates: () => void
|
||||
}
|
||||
|
||||
export function ControlSimilarControls({
|
||||
ctrl, similarControls, loadingSimilar, selectedDuplicates, merging, onToggleDuplicate, onMergeDuplicates,
|
||||
}: ControlSimilarControlsProps) {
|
||||
return (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Search className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Aehnliche Controls</h3>
|
||||
{loadingSimilar && <span className="text-xs text-gray-400">Laden...</span>}
|
||||
</div>
|
||||
|
||||
{similarControls.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-3 p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input type="radio" checked readOnly className="text-purple-600" />
|
||||
<span className="text-sm font-medium text-purple-700">{ctrl.control_id} — {ctrl.title}</span>
|
||||
<span className="text-xs text-gray-400 ml-auto">Behalten (Haupt-Control)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarControls.map(sim => (
|
||||
<div key={sim.control_id} className="p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input type="checkbox" checked={selectedDuplicates.has(sim.control_id)}
|
||||
onChange={() => onToggleDuplicate(sim.control_id)} className="text-red-600" />
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{sim.control_id}</span>
|
||||
<span className="text-sm text-gray-700 flex-1">{sim.title}</span>
|
||||
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
|
||||
{(sim.similarity * 100).toFixed(1)}%
|
||||
</span>
|
||||
<LicenseRuleBadge rule={sim.license_rule} />
|
||||
<VerificationMethodBadge method={sim.verification_method} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedDuplicates.size > 0 && (
|
||||
<button onClick={onMergeDuplicates} disabled={merging}
|
||||
className="mt-3 flex items-center gap-1.5 px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<GitMerge className="w-3.5 h-3.5" />
|
||||
{merging ? 'Zusammenfuehren...' : `${selectedDuplicates.size} Duplikat(e) zusammenfuehren`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
{loadingSimilar ? 'Suche aehnliche Controls...' : 'Keine aehnlichen Controls gefunden.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { ExternalLink, Scale } from 'lucide-react'
|
||||
import type { CanonicalControl } from './helpers'
|
||||
|
||||
export function ControlSourceCitation({ ctrl }: { ctrl: CanonicalControl }) {
|
||||
if (!ctrl.source_citation) return null
|
||||
|
||||
const stype = ctrl.source_citation.source_type
|
||||
const colorSet = stype === 'law'
|
||||
? { bg: 'bg-blue-50 border-blue-200', icon: 'text-blue-600', title: 'text-blue-900', badge: 'bg-blue-100 text-blue-700' }
|
||||
: stype === 'guideline'
|
||||
? { bg: 'bg-indigo-50 border-indigo-200', icon: 'text-indigo-600', title: 'text-indigo-900', badge: 'bg-indigo-100 text-indigo-700' }
|
||||
: { bg: 'bg-teal-50 border-teal-200', icon: 'text-teal-600', title: 'text-teal-900', badge: 'bg-teal-100 text-teal-700' }
|
||||
|
||||
const sectionTitle = stype === 'law' ? 'Gesetzliche Grundlage'
|
||||
: stype === 'guideline' ? 'Behoerdliche Leitlinie'
|
||||
: 'Standard / Best Practice'
|
||||
|
||||
const badgeText = stype === 'law' ? 'Direkte gesetzliche Pflicht'
|
||||
: stype === 'guideline' ? 'Aufsichtsbehoerdliche Empfehlung'
|
||||
: stype === 'standard' || (!stype && ctrl.license_rule === 2) ? 'Freiwilliger Standard'
|
||||
: !stype && ctrl.license_rule === 1 ? 'Noch nicht klassifiziert' : null
|
||||
|
||||
return (
|
||||
<section className={`border rounded-lg p-4 ${colorSet.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className={`w-4 h-4 ${colorSet.icon}`} />
|
||||
<h3 className={`text-sm font-semibold ${colorSet.title}`}>{sectionTitle}</h3>
|
||||
{badgeText && <span className={`text-xs px-2 py-0.5 rounded-full ${colorSet.badge}`}>{badgeText}</span>}
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
{ctrl.source_citation.source ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</p>
|
||||
) : ctrl.generation_metadata?.source_regulation ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">{String(ctrl.generation_metadata.source_regulation)}</p>
|
||||
) : null}
|
||||
{ctrl.source_citation.license && <p className="text-xs text-blue-600">Lizenz: {ctrl.source_citation.license}</p>}
|
||||
{ctrl.source_citation.license_notice && <p className="text-xs text-blue-600 mt-0.5">{ctrl.source_citation.license_notice}</p>}
|
||||
</div>
|
||||
{ctrl.source_citation.url && (
|
||||
<a href={ctrl.source_citation.url} target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 whitespace-nowrap">
|
||||
<ExternalLink className="w-3.5 h-3.5" />Quelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="text-xs text-gray-600 mt-2 p-2 bg-white rounded border border-blue-100 leading-relaxed max-h-40 overflow-y-auto whitespace-pre-wrap">
|
||||
{ctrl.source_original_text}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { Scale, Landmark, GitMerge, FileText } from 'lucide-react'
|
||||
import {
|
||||
SeverityBadge, ObligationTypeBadge, ExtractionMethodBadge,
|
||||
type CanonicalControl, type ObligationInfo, type DocumentReference, type MergedDuplicate, type RegulationSummary,
|
||||
} from './helpers'
|
||||
|
||||
interface TraceabilityData {
|
||||
control_id: string
|
||||
title: string
|
||||
is_atomic: boolean
|
||||
parent_links: Array<{
|
||||
parent_control_id: string
|
||||
parent_title: string
|
||||
link_type: string
|
||||
confidence: number
|
||||
source_regulation: string | null
|
||||
source_article: string | null
|
||||
parent_citation: Record<string, string> | null
|
||||
obligation: {
|
||||
text: string; action: string; object: string; normative_strength: string
|
||||
} | null
|
||||
}>
|
||||
children: Array<{
|
||||
control_id: string; title: string; category: string; severity: string; decomposition_method: string
|
||||
}>
|
||||
source_count: number
|
||||
obligations?: ObligationInfo[]
|
||||
obligation_count?: number
|
||||
document_references?: DocumentReference[]
|
||||
merged_duplicates?: MergedDuplicate[]
|
||||
merged_duplicates_count?: number
|
||||
regulations_summary?: RegulationSummary[]
|
||||
}
|
||||
|
||||
interface ControlTraceabilityProps {
|
||||
ctrl: CanonicalControl
|
||||
traceability: TraceabilityData | null
|
||||
loadingTrace: boolean
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
}
|
||||
|
||||
export function ControlTraceability({ ctrl, traceability, loadingTrace, onNavigateToControl }: ControlTraceabilityProps) {
|
||||
const ControlLink = ({ controlId }: { controlId: string }) => {
|
||||
if (onNavigateToControl) {
|
||||
return (
|
||||
<button onClick={() => onNavigateToControl(controlId)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline">
|
||||
{controlId}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{controlId}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Regulatorische Abdeckung (Eigenentwicklung) covered by parent */}
|
||||
|
||||
{/* Rechtsgrundlagen / Traceability */}
|
||||
{traceability && traceability.parent_links.length > 0 && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Landmark className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">
|
||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
||||
</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{traceability.regulations_summary?.map(rs => (
|
||||
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
|
||||
{rs.regulation_code}
|
||||
</span>
|
||||
))}
|
||||
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{traceability.parent_links.map((link, i) => (
|
||||
<div key={i} className="bg-white/60 border border-violet-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Scale className="w-4 h-4 text-violet-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{link.source_regulation && <span className="text-sm font-semibold text-violet-900">{link.source_regulation}</span>}
|
||||
{link.source_article && <span className="text-sm text-violet-700">{link.source_article}</span>}
|
||||
{!link.source_regulation && link.parent_citation?.source && (
|
||||
<span className="text-sm font-semibold text-violet-900">
|
||||
{link.parent_citation.source}{link.parent_citation.article && ` — ${link.parent_citation.article}`}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
link.link_type === 'decomposition' ? 'bg-violet-100 text-violet-600' :
|
||||
link.link_type === 'dedup_merge' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{link.link_type === 'decomposition' ? 'Ableitung' : link.link_type === 'dedup_merge' ? 'Dedup' : link.link_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-violet-600 mt-1">
|
||||
via <ControlLink controlId={link.parent_control_id} />
|
||||
{link.parent_title && <span className="text-violet-500 ml-1">— {link.parent_title}</span>}
|
||||
</p>
|
||||
{link.obligation && (
|
||||
<p className="text-xs text-violet-500 mt-1.5 bg-violet-50 rounded p-2">
|
||||
<span className={`inline-block mr-1.5 px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
link.obligation.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
link.obligation.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{link.obligation.normative_strength === 'must' ? 'MUSS' : link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{link.obligation.text.slice(0, 200)}{link.obligation.text.length > 200 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Fallback: simple parent display */}
|
||||
{ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<GitMerge className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">Atomares Control</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<p className="text-sm text-violet-800">
|
||||
Abgeleitet aus Eltern-Control{' '}
|
||||
<span className="font-mono font-semibold text-purple-700 bg-purple-100 px-1.5 py-0.5 rounded">
|
||||
{ctrl.parent_control_id || ctrl.parent_control_uuid}
|
||||
</span>
|
||||
{ctrl.parent_control_title && <span className="text-violet-700 ml-1">— {ctrl.parent_control_title}</span>}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Document References */}
|
||||
{traceability?.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
|
||||
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-indigo-600" />
|
||||
<h3 className="text-sm font-semibold text-indigo-900">Original-Dokumente ({traceability.document_references.length})</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.document_references.map((dr, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
|
||||
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
|
||||
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
|
||||
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<ExtractionMethodBadge method={dr.extraction_method} />
|
||||
{dr.confidence !== null && <span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Obligations */}
|
||||
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.obligations.map((ob) => (
|
||||
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{ob.normative_strength === 'must' ? 'MUSS' : ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
|
||||
{ob.object && <span className="text-xs text-amber-500">→ {ob.object}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
{ob.obligation_text.slice(0, 300)}{ob.obligation_text.length > 300 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Merged Duplicates */}
|
||||
{traceability?.merged_duplicates && traceability.merged_duplicates.length > 0 && (
|
||||
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.merged_duplicates.map((dup) => (
|
||||
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
|
||||
<ControlLink controlId={dup.control_id} />
|
||||
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
|
||||
{dup.source_regulation && <span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Child controls */}
|
||||
{traceability && traceability.children.length > 0 && (
|
||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-emerald-900">Abgeleitete Controls ({traceability.children.length})</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.children.map((child) => (
|
||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
||||
<ControlLink controlId={child.control_id} />
|
||||
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
||||
<SeverityBadge severity={child.severity} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { CanonicalControl, BACKEND_URL } from './helpers'
|
||||
|
||||
export interface ControlFormData {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface CRUDDeps {
|
||||
selectedControl: CanonicalControl | null
|
||||
fullReload: () => Promise<void>
|
||||
reviewMode: boolean
|
||||
reviewIndex: number
|
||||
reviewItems: CanonicalControl[]
|
||||
setMode: (m: 'list' | 'detail' | 'create' | 'edit') => void
|
||||
setSelectedControl: (c: CanonicalControl | null) => void
|
||||
setReviewMode: (v: boolean) => void
|
||||
setReviewItems: (items: CanonicalControl[]) => void
|
||||
setReviewIndex: (i: number) => void
|
||||
setSaving: (v: boolean) => void
|
||||
setBulkProcessing: (v: boolean) => void
|
||||
reviewCount: number
|
||||
totalCount: number
|
||||
stateFilter: string
|
||||
}
|
||||
|
||||
export function createCRUDHandlers(deps: CRUDDeps) {
|
||||
const {
|
||||
selectedControl, fullReload, reviewMode, reviewIndex, reviewItems,
|
||||
setMode, setSelectedControl, setReviewMode, setReviewItems, setReviewIndex,
|
||||
setSaving, setBulkProcessing, reviewCount, totalCount, stateFilter,
|
||||
} = deps
|
||||
|
||||
const handleCreate = async (data: ControlFormData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=create-control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
|
||||
await fullReload()
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleUpdate = async (data: ControlFormData) => {
|
||||
if (!selectedControl) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=update-control&id=${selectedControl.control_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
|
||||
await fullReload()
|
||||
setSelectedControl(null)
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (controlId: string) => {
|
||||
if (!confirm(`Control ${controlId} wirklich loeschen?`)) return
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?id=${controlId}`, { method: 'DELETE' })
|
||||
if (!res.ok && res.status !== 204) { alert('Fehler beim Loeschen'); return }
|
||||
await fullReload()
|
||||
setSelectedControl(null)
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') }
|
||||
}
|
||||
|
||||
const handleReview = async (controlId: string, action: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=review&id=${controlId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fullReload()
|
||||
if (reviewMode) {
|
||||
const remaining = reviewItems.filter(c => c.control_id !== controlId)
|
||||
setReviewItems(remaining)
|
||||
if (remaining.length > 0) {
|
||||
const nextIdx = Math.min(reviewIndex, remaining.length - 1)
|
||||
setReviewIndex(nextIdx)
|
||||
setSelectedControl(remaining[nextIdx])
|
||||
} else { setReviewMode(false); setSelectedControl(null); setMode('list') }
|
||||
} else { setSelectedControl(null); setMode('list') }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleBulkReject = async (sourceState: string) => {
|
||||
const count = stateFilter === sourceState ? totalCount : reviewCount
|
||||
if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return
|
||||
setBulkProcessing(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ release_state: sourceState, action: 'reject' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`)
|
||||
await fullReload()
|
||||
} else { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) }
|
||||
} catch { alert('Netzwerkfehler') } finally { setBulkProcessing(false) }
|
||||
}
|
||||
|
||||
return { handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject }
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { CanonicalControl, Framework, BACKEND_URL } from './helpers'
|
||||
|
||||
export interface ControlsMeta {
|
||||
total: number
|
||||
domains: Array<{ domain: string; count: number }>
|
||||
sources: Array<{ source: string; count: number }>
|
||||
no_source_count: number
|
||||
type_counts?: { rich: number; atomic: number; eigenentwicklung: number }
|
||||
severity_counts?: Record<string, number>
|
||||
verification_method_counts?: Record<string, number>
|
||||
category_counts?: Record<string, number>
|
||||
evidence_type_counts?: Record<string, number>
|
||||
release_state_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useControlLibraryState() {
|
||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [meta, setMeta] = useState<ControlsMeta | null>(null)
|
||||
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
const [stateFilter, setStateFilter] = useState<string>('')
|
||||
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
||||
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||
const [hideDuplicates, setHideDuplicates] = useState(true)
|
||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id')
|
||||
|
||||
// CRUD / UI state
|
||||
const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
const [processedStats, setProcessedStats] = useState<Array<Record<string, unknown>>>([])
|
||||
const [showStats, setShowStats] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false)
|
||||
|
||||
// Review state
|
||||
const [reviewMode, setReviewMode] = useState(false)
|
||||
const [reviewIndex, setReviewIndex] = useState(0)
|
||||
const [reviewItems, setReviewItems] = useState<CanonicalControl[]>([])
|
||||
const [reviewCount, setReviewCount] = useState(0)
|
||||
const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates')
|
||||
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
||||
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
||||
|
||||
// V1 Compare state
|
||||
const [compareMode, setCompareMode] = useState(false)
|
||||
const [compareV1Control, setCompareV1Control] = useState<CanonicalControl | null>(null)
|
||||
const [compareMatches, setCompareMatches] = useState<Array<{
|
||||
matched_control_id: string; matched_title: string; matched_objective: string
|
||||
matched_severity: string; matched_category: string
|
||||
matched_source: string | null; matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number; match_rank: number; match_method: string
|
||||
}>>([])
|
||||
|
||||
const metaAbortRef = useRef<AbortController | null>(null)
|
||||
const controlsAbortRef = useRef<AbortController | null>(null)
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [searchQuery])
|
||||
|
||||
const buildParams = useCallback((extra?: Record<string, string>) => {
|
||||
const p = new URLSearchParams()
|
||||
if (severityFilter) p.set('severity', severityFilter)
|
||||
if (domainFilter) p.set('domain', domainFilter)
|
||||
if (stateFilter) p.set('release_state', stateFilter)
|
||||
if (verificationFilter) p.set('verification_method', verificationFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
||||
if (audienceFilter) p.set('target_audience', audienceFilter)
|
||||
if (sourceFilter) p.set('source', sourceFilter)
|
||||
if (typeFilter) p.set('control_type', typeFilter)
|
||||
if (hideDuplicates) p.set('exclude_duplicates', 'true')
|
||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||
return p.toString()
|
||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
||||
|
||||
const loadFrameworks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
||||
if (res.ok) setFrameworks(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
const loadMeta = useCallback(async () => {
|
||||
if (metaAbortRef.current) metaAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
metaAbortRef.current = controller
|
||||
try {
|
||||
const qs = buildParams()
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||
}
|
||||
}, [buildParams])
|
||||
|
||||
const loadControls = useCallback(async () => {
|
||||
if (controlsAbortRef.current) controlsAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
controlsAbortRef.current = controller
|
||||
try {
|
||||
setLoading(true)
|
||||
const sortField = sortBy === 'id' ? 'control_id' : sortBy === 'source' ? 'source' : 'created_at'
|
||||
const sortOrder = sortBy === 'newest' ? 'desc' : sortBy === 'oldest' ? 'asc' : 'asc'
|
||||
const offset = (currentPage - 1) * PAGE_SIZE
|
||||
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
||||
const countQs = buildParams()
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||
])
|
||||
if (!controller.signal.aborted) {
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
if (countRes.ok) { const data = await countRes.json(); setTotalCount(data.total || 0) }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [buildParams, sortBy, currentPage])
|
||||
|
||||
const loadReviewCount = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
|
||||
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
||||
useEffect(() => { loadMeta() }, [loadMeta])
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
const fullReload = useCallback(async () => {
|
||||
await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
|
||||
}, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
|
||||
|
||||
const loadProcessedStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
||||
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const enterReviewMode = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||
if (res.ok) {
|
||||
const items: CanonicalControl[] = await res.json()
|
||||
if (items.length > 0) {
|
||||
const dupes = items.filter(c => c.generation_metadata?.similar_controls && Array.isArray(c.generation_metadata.similar_controls) && (c.generation_metadata.similar_controls as unknown[]).length > 0)
|
||||
const rule3 = items.filter(c => !c.generation_metadata?.similar_controls || !Array.isArray(c.generation_metadata.similar_controls) || (c.generation_metadata.similar_controls as unknown[]).length === 0)
|
||||
setReviewDuplicates(dupes)
|
||||
setReviewRule3(rule3)
|
||||
const startTab = dupes.length > 0 ? 'duplicates' : 'rule3'
|
||||
const startItems = startTab === 'duplicates' ? dupes : rule3
|
||||
setReviewTab(startTab)
|
||||
setReviewItems(startItems)
|
||||
setReviewMode(true)
|
||||
setReviewIndex(0)
|
||||
setSelectedControl(startItems[0])
|
||||
setMode('detail')
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const switchReviewTab = (tab: 'duplicates' | 'rule3') => {
|
||||
const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3
|
||||
setReviewTab(tab)
|
||||
setReviewItems(items)
|
||||
setReviewIndex(0)
|
||||
if (items.length > 0) setSelectedControl(items[0])
|
||||
}
|
||||
|
||||
return {
|
||||
// Data
|
||||
frameworks, controls, totalCount, meta, selectedControl, setSelectedControl,
|
||||
loading, error,
|
||||
// Filters
|
||||
searchQuery, setSearchQuery, debouncedSearch,
|
||||
severityFilter, setSeverityFilter,
|
||||
domainFilter, setDomainFilter,
|
||||
stateFilter, setStateFilter,
|
||||
verificationFilter, setVerificationFilter,
|
||||
categoryFilter, setCategoryFilter,
|
||||
evidenceTypeFilter, setEvidenceTypeFilter,
|
||||
audienceFilter, setAudienceFilter,
|
||||
sourceFilter, setSourceFilter,
|
||||
typeFilter, setTypeFilter,
|
||||
hideDuplicates, setHideDuplicates,
|
||||
sortBy, setSortBy,
|
||||
// CRUD/UI
|
||||
mode, setMode, saving, setSaving,
|
||||
showGenerator, setShowGenerator,
|
||||
processedStats, showStats, setShowStats,
|
||||
currentPage, setCurrentPage, totalPages,
|
||||
bulkProcessing, setBulkProcessing,
|
||||
// Review
|
||||
reviewMode, setReviewMode,
|
||||
reviewIndex, setReviewIndex,
|
||||
reviewItems, setReviewItems,
|
||||
reviewCount, reviewTab,
|
||||
reviewDuplicates, reviewRule3,
|
||||
// V1 Compare
|
||||
compareMode, setCompareMode,
|
||||
compareV1Control, setCompareV1Control,
|
||||
compareMatches, setCompareMatches,
|
||||
// Actions
|
||||
fullReload, loadControls, loadMeta, loadFrameworks, loadReviewCount,
|
||||
loadProcessedStats, enterReviewMode, switchReviewTab,
|
||||
}
|
||||
}
|
||||
@@ -1,93 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react'
|
||||
import { EMPTY_CONTROL } from './components/helpers'
|
||||
import { ControlForm } from './components/ControlForm'
|
||||
import { ControlDetail } from './components/ControlDetail'
|
||||
import { ReviewCompare } from './components/ReviewCompare'
|
||||
import { V1CompareView } from './components/V1CompareView'
|
||||
import { GeneratorModal } from './components/GeneratorModal'
|
||||
import { ControlsHeader } from './components/ControlsHeader'
|
||||
import { ControlListItem } from './components/ControlListItem'
|
||||
import { useControlLibrary } from './components/useControlLibrary'
|
||||
import { ControlListView } from './components/ControlListView'
|
||||
import { useControlLibraryState } from './components/useControlLibraryState'
|
||||
import { createCRUDHandlers } from './components/useControlCRUD'
|
||||
import { BACKEND_URL } from './components/helpers'
|
||||
|
||||
export default function ControlLibraryPage() {
|
||||
const lib = useControlLibrary()
|
||||
const state = useControlLibraryState()
|
||||
|
||||
if (lib.loading && lib.controls.length === 0) {
|
||||
const {
|
||||
handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject,
|
||||
} = createCRUDHandlers({
|
||||
selectedControl: state.selectedControl,
|
||||
fullReload: state.fullReload,
|
||||
reviewMode: state.reviewMode,
|
||||
reviewIndex: state.reviewIndex,
|
||||
reviewItems: state.reviewItems,
|
||||
setMode: state.setMode,
|
||||
setSelectedControl: state.setSelectedControl,
|
||||
setReviewMode: state.setReviewMode,
|
||||
setReviewItems: state.setReviewItems,
|
||||
setReviewIndex: state.setReviewIndex,
|
||||
setSaving: state.setSaving,
|
||||
setBulkProcessing: state.setBulkProcessing,
|
||||
reviewCount: state.reviewCount,
|
||||
totalCount: state.totalCount,
|
||||
stateFilter: state.stateFilter,
|
||||
})
|
||||
|
||||
// Loading / error screens
|
||||
if (state.loading && state.controls.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (lib.error) {
|
||||
return <div className="flex items-center justify-center h-96"><p className="text-red-600">{lib.error}</p></div>
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<p className="text-red-600">{state.error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (lib.mode === 'create') {
|
||||
return <ControlForm initial={EMPTY_CONTROL} onSave={lib.handleCreate} onCancel={() => lib.setMode('list')} saving={lib.saving} />
|
||||
// CREATE mode
|
||||
if (state.mode === 'create') {
|
||||
return <ControlForm initial={EMPTY_CONTROL} onSave={handleCreate} onCancel={() => state.setMode('list')} saving={state.saving} />
|
||||
}
|
||||
if (lib.mode === 'edit' && lib.selectedControl) {
|
||||
|
||||
// EDIT mode
|
||||
if (state.mode === 'edit' && state.selectedControl) {
|
||||
return (
|
||||
<ControlForm
|
||||
initial={{
|
||||
...EMPTY_CONTROL, ...lib.selectedControl,
|
||||
risk_score: lib.selectedControl.risk_score,
|
||||
implementation_effort: lib.selectedControl.implementation_effort,
|
||||
open_anchors: lib.selectedControl.open_anchors.length > 0 ? lib.selectedControl.open_anchors : [{ framework: '', ref: '', url: '' }],
|
||||
requirements: lib.selectedControl.requirements.length > 0 ? lib.selectedControl.requirements : [''],
|
||||
test_procedure: lib.selectedControl.test_procedure.length > 0 ? lib.selectedControl.test_procedure : [''],
|
||||
evidence: lib.selectedControl.evidence.length > 0 ? lib.selectedControl.evidence : [{ type: '', description: '' }],
|
||||
...EMPTY_CONTROL,
|
||||
...state.selectedControl,
|
||||
risk_score: state.selectedControl.risk_score,
|
||||
implementation_effort: state.selectedControl.implementation_effort,
|
||||
open_anchors: state.selectedControl.open_anchors.length > 0
|
||||
? state.selectedControl.open_anchors
|
||||
: [{ framework: '', ref: '', url: '' }],
|
||||
requirements: state.selectedControl.requirements.length > 0 ? state.selectedControl.requirements : [''],
|
||||
test_procedure: state.selectedControl.test_procedure.length > 0 ? state.selectedControl.test_procedure : [''],
|
||||
evidence: state.selectedControl.evidence.length > 0 ? state.selectedControl.evidence : [{ type: '', description: '' }],
|
||||
}}
|
||||
onSave={lib.handleUpdate} onCancel={() => lib.setMode('detail')} saving={lib.saving}
|
||||
onSave={handleUpdate}
|
||||
onCancel={() => state.setMode('detail')}
|
||||
saving={state.saving}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (lib.compareMode && lib.compareV1Control) {
|
||||
// V1 COMPARE mode
|
||||
if (state.compareMode && state.compareV1Control) {
|
||||
return (
|
||||
<V1CompareView
|
||||
v1Control={lib.compareV1Control} matches={lib.compareMatches}
|
||||
onBack={() => lib.setCompareMode(false)}
|
||||
v1Control={state.compareV1Control}
|
||||
matches={state.compareMatches}
|
||||
onBack={() => state.setCompareMode(false)}
|
||||
onNavigateToControl={async (controlId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||
if (res.ok) { lib.setCompareMode(false); lib.setSelectedControl(await res.json()); lib.setMode('detail') }
|
||||
if (res.ok) { state.setCompareMode(false); state.setSelectedControl(await res.json()); state.setMode('detail') }
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (lib.mode === 'detail' && lib.selectedControl) {
|
||||
const isDuplicateReview = lib.reviewMode && lib.reviewTab === 'duplicates'
|
||||
const reviewTabBar = lib.reviewMode ? (
|
||||
// DETAIL mode
|
||||
if (state.mode === 'detail' && state.selectedControl) {
|
||||
const isDuplicateReview = state.reviewMode && state.reviewTab === 'duplicates'
|
||||
|
||||
const reviewTabBar = state.reviewMode ? (
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-2 flex items-center gap-4">
|
||||
<button onClick={() => lib.switchReviewTab('duplicates')}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${lib.reviewTab === 'duplicates' ? 'bg-amber-100 text-amber-800 border border-amber-300' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}>
|
||||
Duplikat-Verdacht ({lib.reviewDuplicates.length})
|
||||
<button
|
||||
onClick={() => state.switchReviewTab('duplicates')}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${state.reviewTab === 'duplicates' ? 'bg-amber-100 text-amber-800 border border-amber-300' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
|
||||
>
|
||||
Duplikat-Verdacht ({state.reviewDuplicates.length})
|
||||
</button>
|
||||
<button onClick={() => lib.switchReviewTab('rule3')}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${lib.reviewTab === 'rule3' ? 'bg-purple-100 text-purple-800 border border-purple-300' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}>
|
||||
Rule 3 ohne Anchor ({lib.reviewRule3.length})
|
||||
<button
|
||||
onClick={() => state.switchReviewTab('rule3')}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${state.reviewTab === 'rule3' ? 'bg-purple-100 text-purple-800 border border-purple-300' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
|
||||
>
|
||||
Rule 3 ohne Anchor ({state.reviewRule3.length})
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const reviewNavProps = {
|
||||
reviewMode: state.reviewMode,
|
||||
reviewIndex: state.reviewIndex,
|
||||
reviewTotal: state.reviewItems.length,
|
||||
onReviewPrev: () => {
|
||||
const idx = Math.max(0, state.reviewIndex - 1)
|
||||
state.setReviewIndex(idx)
|
||||
state.setSelectedControl(state.reviewItems[idx])
|
||||
},
|
||||
onReviewNext: () => {
|
||||
const idx = Math.min(state.reviewItems.length - 1, state.reviewIndex + 1)
|
||||
state.setReviewIndex(idx)
|
||||
state.setSelectedControl(state.reviewItems[idx])
|
||||
},
|
||||
}
|
||||
|
||||
if (isDuplicateReview) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{reviewTabBar}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ReviewCompare
|
||||
ctrl={lib.selectedControl}
|
||||
onBack={() => { lib.setMode('list'); lib.setSelectedControl(null); lib.setReviewMode(false) }}
|
||||
onReview={lib.handleReview} onEdit={() => lib.setMode('edit')}
|
||||
reviewIndex={lib.reviewIndex} reviewTotal={lib.reviewItems.length}
|
||||
onReviewPrev={() => { const idx = Math.max(0, lib.reviewIndex - 1); lib.setReviewIndex(idx); lib.setSelectedControl(lib.reviewItems[idx]) }}
|
||||
onReviewNext={() => { const idx = Math.min(lib.reviewItems.length - 1, lib.reviewIndex + 1); lib.setReviewIndex(idx); lib.setSelectedControl(lib.reviewItems[idx]) }}
|
||||
ctrl={state.selectedControl}
|
||||
onBack={() => { state.setMode('list'); state.setSelectedControl(null); state.setReviewMode(false) }}
|
||||
onReview={handleReview}
|
||||
onEdit={() => state.setMode('edit')}
|
||||
reviewIndex={state.reviewIndex}
|
||||
reviewTotal={state.reviewItems.length}
|
||||
onReviewPrev={reviewNavProps.onReviewPrev}
|
||||
onReviewNext={reviewNavProps.onReviewNext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,127 +157,79 @@ export default function ControlLibraryPage() {
|
||||
{reviewTabBar}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ControlDetail
|
||||
ctrl={lib.selectedControl}
|
||||
onBack={() => { lib.setMode('list'); lib.setSelectedControl(null); lib.setReviewMode(false) }}
|
||||
onEdit={() => lib.setMode('edit')} onDelete={lib.handleDelete}
|
||||
onReview={lib.handleReview} onRefresh={lib.fullReload}
|
||||
onCompare={(ctrl, matches) => { lib.setCompareV1Control(ctrl); lib.setCompareMatches(matches); lib.setCompareMode(true) }}
|
||||
ctrl={state.selectedControl}
|
||||
onBack={() => { state.setMode('list'); state.setSelectedControl(null); state.setReviewMode(false) }}
|
||||
onEdit={() => state.setMode('edit')}
|
||||
onDelete={handleDelete}
|
||||
onReview={handleReview}
|
||||
onRefresh={state.fullReload}
|
||||
onCompare={(ctrl, matches) => { state.setCompareV1Control(ctrl); state.setCompareMatches(matches); state.setCompareMode(true) }}
|
||||
onNavigateToControl={async (controlId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||
if (res.ok) { lib.setSelectedControl(await res.json()); lib.setMode('detail') }
|
||||
if (res.ok) { state.setSelectedControl(await res.json()); state.setMode('detail') }
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
reviewMode={lib.reviewMode} reviewIndex={lib.reviewIndex} reviewTotal={lib.reviewItems.length}
|
||||
onReviewPrev={() => { const idx = Math.max(0, lib.reviewIndex - 1); lib.setReviewIndex(idx); lib.setSelectedControl(lib.reviewItems[idx]) }}
|
||||
onReviewNext={() => { const idx = Math.min(lib.reviewItems.length - 1, lib.reviewIndex + 1); lib.setReviewIndex(idx); lib.setSelectedControl(lib.reviewItems[idx]) }}
|
||||
reviewMode={state.reviewMode}
|
||||
reviewIndex={state.reviewIndex}
|
||||
reviewTotal={state.reviewItems.length}
|
||||
onReviewPrev={reviewNavProps.onReviewPrev}
|
||||
onReviewNext={reviewNavProps.onReviewNext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// LIST VIEW
|
||||
// LIST mode
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ControlsHeader
|
||||
frameworks={lib.frameworks} meta={lib.meta} reviewCount={lib.reviewCount}
|
||||
loading={lib.loading} bulkProcessing={lib.bulkProcessing}
|
||||
showStats={lib.showStats} processedStats={lib.processedStats}
|
||||
searchQuery={lib.searchQuery} severityFilter={lib.severityFilter}
|
||||
domainFilter={lib.domainFilter} stateFilter={lib.stateFilter}
|
||||
hideDuplicates={lib.hideDuplicates} verificationFilter={lib.verificationFilter}
|
||||
categoryFilter={lib.categoryFilter} evidenceTypeFilter={lib.evidenceTypeFilter}
|
||||
audienceFilter={lib.audienceFilter} sourceFilter={lib.sourceFilter}
|
||||
typeFilter={lib.typeFilter} sortBy={lib.sortBy}
|
||||
onSearchChange={lib.setSearchQuery} onSeverityChange={lib.setSeverityFilter}
|
||||
onDomainChange={lib.setDomainFilter} onStateChange={lib.setStateFilter}
|
||||
onHideDuplicatesChange={lib.setHideDuplicates} onVerificationChange={lib.setVerificationFilter}
|
||||
onCategoryChange={lib.setCategoryFilter} onEvidenceTypeChange={lib.setEvidenceTypeFilter}
|
||||
onAudienceChange={lib.setAudienceFilter} onSourceChange={lib.setSourceFilter}
|
||||
onTypeChange={lib.setTypeFilter}
|
||||
onSortChange={v => lib.setSortBy(v as 'id' | 'newest' | 'oldest' | 'source')}
|
||||
onRefresh={() => { lib.loadControls(); lib.loadMeta(); lib.loadFrameworks(); lib.loadReviewCount() }}
|
||||
onEnterReviewMode={lib.enterReviewMode} onBulkReject={lib.handleBulkReject}
|
||||
onToggleStats={() => { lib.setShowStats(!lib.showStats); if (!lib.showStats) lib.loadProcessedStats() }}
|
||||
onOpenGenerator={() => lib.setShowGenerator(true)} onCreateNew={() => lib.setMode('create')}
|
||||
/>
|
||||
|
||||
{lib.showGenerator && (
|
||||
<GeneratorModal onClose={() => lib.setShowGenerator(false)} onComplete={() => lib.fullReload()} />
|
||||
)}
|
||||
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{lib.totalCount} Controls gefunden
|
||||
{lib.totalCount !== (lib.meta?.total ?? lib.totalCount) && ` (von ${lib.meta?.total} gesamt)`}
|
||||
{lib.loading && <span className="ml-2 text-purple-500">Lade...</span>}
|
||||
</span>
|
||||
{lib.stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(lib.stateFilter) && lib.totalCount > 0 && (
|
||||
<button onClick={() => lib.handleBulkReject(lib.stateFilter)} disabled={lib.bulkProcessing}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-white bg-red-600 rounded hover:bg-red-700 disabled:opacity-50">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{lib.bulkProcessing ? '...' : `Alle ${lib.totalCount} ablehnen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span>Seite {lib.currentPage} von {lib.totalPages}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{lib.controls.map((ctrl, idx) => (
|
||||
<ControlListItem
|
||||
key={ctrl.control_id} ctrl={ctrl} sortBy={lib.sortBy}
|
||||
prevSource={idx > 0 ? (lib.controls[idx - 1].source_citation?.source || 'Ohne Quelle') : null}
|
||||
onClick={() => { lib.setSelectedControl(ctrl); lib.setMode('detail') }}
|
||||
/>
|
||||
))}
|
||||
{lib.controls.length === 0 && !lib.loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
{lib.totalCount === 0 && !lib.debouncedSearch && !lib.severityFilter && !lib.domainFilter
|
||||
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
|
||||
: 'Keine Controls gefunden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lib.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||
<button onClick={() => lib.setCurrentPage(1)} disabled={lib.currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Erste Seite">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => lib.setCurrentPage(p => Math.max(1, p - 1))} disabled={lib.currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Vorherige Seite">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: lib.totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === lib.totalPages || Math.abs(p - lib.currentPage) <= 2)
|
||||
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||
acc.push(p); return acc
|
||||
}, [])
|
||||
.map((p, i) => p === 'dots' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||
) : (
|
||||
<button key={p} onClick={() => lib.setCurrentPage(p as number)}
|
||||
className={`w-8 h-8 text-sm rounded-lg ${lib.currentPage === p ? 'bg-purple-600 text-white' : 'text-gray-600 hover:bg-purple-50 hover:text-purple-600'}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => lib.setCurrentPage(p => Math.min(lib.totalPages, p + 1))} disabled={lib.currentPage === lib.totalPages}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Naechste Seite">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => lib.setCurrentPage(lib.totalPages)} disabled={lib.currentPage === lib.totalPages}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Letzte Seite">
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ControlListView
|
||||
frameworks={state.frameworks}
|
||||
controls={state.controls}
|
||||
totalCount={state.totalCount}
|
||||
meta={state.meta}
|
||||
loading={state.loading}
|
||||
reviewCount={state.reviewCount}
|
||||
bulkProcessing={state.bulkProcessing}
|
||||
showStats={state.showStats}
|
||||
processedStats={state.processedStats}
|
||||
showGenerator={state.showGenerator}
|
||||
currentPage={state.currentPage}
|
||||
totalPages={state.totalPages}
|
||||
sortBy={state.sortBy}
|
||||
searchQuery={state.searchQuery}
|
||||
severityFilter={state.severityFilter}
|
||||
domainFilter={state.domainFilter}
|
||||
stateFilter={state.stateFilter}
|
||||
verificationFilter={state.verificationFilter}
|
||||
categoryFilter={state.categoryFilter}
|
||||
evidenceTypeFilter={state.evidenceTypeFilter}
|
||||
audienceFilter={state.audienceFilter}
|
||||
sourceFilter={state.sourceFilter}
|
||||
typeFilter={state.typeFilter}
|
||||
hideDuplicates={state.hideDuplicates}
|
||||
setSearchQuery={state.setSearchQuery}
|
||||
setSeverityFilter={state.setSeverityFilter}
|
||||
setDomainFilter={state.setDomainFilter}
|
||||
setStateFilter={state.setStateFilter}
|
||||
setVerificationFilter={state.setVerificationFilter}
|
||||
setCategoryFilter={state.setCategoryFilter}
|
||||
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
|
||||
setAudienceFilter={state.setAudienceFilter}
|
||||
setSourceFilter={state.setSourceFilter}
|
||||
setTypeFilter={state.setTypeFilter}
|
||||
setHideDuplicates={state.setHideDuplicates}
|
||||
setSortBy={state.setSortBy}
|
||||
setShowStats={state.setShowStats}
|
||||
setShowGenerator={state.setShowGenerator}
|
||||
setCurrentPage={state.setCurrentPage}
|
||||
onSelectControl={(ctrl) => { state.setSelectedControl(ctrl); state.setMode('detail') }}
|
||||
onCreateMode={() => state.setMode('create')}
|
||||
onEnterReview={state.enterReviewMode}
|
||||
onBulkReject={handleBulkReject}
|
||||
onRefresh={() => { state.loadControls(); state.loadMeta(); state.loadFrameworks(); state.loadReviewCount() }}
|
||||
onLoadStats={state.loadProcessedStats}
|
||||
onFullReload={state.fullReload}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user