All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 39s
CI/CD / test-python-backend-compliance (push) Successful in 32s
CI/CD / test-python-document-crawler (push) Successful in 20s
CI/CD / test-python-dsms-gateway (push) Successful in 16s
CI/CD / validate-canonical-controls (push) Successful in 9s
CI/CD / Deploy (push) Successful in 4s
863 v1-Controls (manuell geschrieben, ohne Rechtsgrundlage) werden als "Eigenentwicklung" gekennzeichnet und automatisch mit regulatorischen Controls (DSGVO, NIS2, OWASP etc.) per Embedding-Similarity abgeglichen. Backend: - Migration 080: v1_control_matches Tabelle (Cross-Reference) - v1_enrichment.py: Batch-Matching via BGE-M3 + Qdrant (Threshold 0.75) - 3 neue API-Endpoints: enrich-v1-matches, v1-matches, v1-enrichment-stats - 6 Tests (dry-run, execution, matches, pagination, detection) Frontend: - Orange "Eigenentwicklung"-Badge statt grauem "v1" (wenn kein Source) - "Regulatorische Abdeckung"-Sektion im ControlDetail mit Match-Karten - Side-by-Side V1CompareView (Eigenentwicklung vs. regulatorisch gedeckt) - Prev/Next Navigation durch alle Matches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
964 lines
40 KiB
TypeScript
964 lines
40 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import {
|
||
Shield, Search, ChevronRight, ChevronLeft, Filter, Lock,
|
||
BookOpen, Plus, Zap, BarChart3, ListChecks, Trash2,
|
||
ChevronsLeft, ChevronsRight, ArrowUpDown, Clock, RefreshCw,
|
||
} from 'lucide-react'
|
||
import {
|
||
CanonicalControl, Framework, BACKEND_URL, EMPTY_CONTROL,
|
||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||
GenerationStrategyBadge, ObligationTypeBadge,
|
||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS, TARGET_AUDIENCE_OPTIONS,
|
||
} 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'
|
||
|
||
// =============================================================================
|
||
// TYPES
|
||
// =============================================================================
|
||
|
||
interface ControlsMeta {
|
||
total: number
|
||
domains: Array<{ domain: string; count: number }>
|
||
sources: Array<{ source: string; count: number }>
|
||
no_source_count: number
|
||
}
|
||
|
||
// =============================================================================
|
||
// CONTROL LIBRARY PAGE — Server-Side Pagination
|
||
// =============================================================================
|
||
|
||
const PAGE_SIZE = 50
|
||
|
||
export default function ControlLibraryPage() {
|
||
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 state
|
||
const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list')
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
// Generator state
|
||
const [showGenerator, setShowGenerator] = useState(false)
|
||
const [processedStats, setProcessedStats] = useState<Array<Record<string, unknown>>>([])
|
||
const [showStats, setShowStats] = useState(false)
|
||
|
||
// Pagination
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
|
||
// Review mode
|
||
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 mode
|
||
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
|
||
}>>([])
|
||
|
||
// Debounce search
|
||
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])
|
||
|
||
// Build query params for backend
|
||
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])
|
||
|
||
// Load metadata (domains, sources — once + on refresh)
|
||
const loadMeta = useCallback(async () => {
|
||
try {
|
||
const [fwRes, metaRes] = await Promise.all([
|
||
fetch(`${BACKEND_URL}?endpoint=frameworks`),
|
||
fetch(`${BACKEND_URL}?endpoint=controls-meta`),
|
||
])
|
||
if (fwRes.ok) setFrameworks(await fwRes.json())
|
||
if (metaRes.ok) setMeta(await metaRes.json())
|
||
} catch { /* ignore */ }
|
||
}, [])
|
||
|
||
// Load controls page
|
||
const loadControls = useCallback(async () => {
|
||
try {
|
||
setLoading(true)
|
||
|
||
// Determine sort
|
||
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}`),
|
||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
||
])
|
||
|
||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||
if (countRes.ok) {
|
||
const data = await countRes.json()
|
||
setTotalCount(data.total || 0)
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [buildParams, sortBy, currentPage])
|
||
|
||
// Load review count
|
||
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 */ }
|
||
}, [])
|
||
|
||
// Initial load
|
||
useEffect(() => { loadMeta(); loadReviewCount() }, [loadMeta, loadReviewCount])
|
||
|
||
// Load controls when filters/page/sort change
|
||
useEffect(() => { loadControls() }, [loadControls])
|
||
|
||
// Reset page when filters change
|
||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
||
|
||
// Pagination
|
||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||
|
||
// Full reload (after CRUD)
|
||
const fullReload = useCallback(async () => {
|
||
await Promise.all([loadControls(), loadMeta(), loadReviewCount()])
|
||
}, [loadControls, loadMeta, loadReviewCount])
|
||
|
||
// CRUD handlers
|
||
const handleCreate = async (data: typeof EMPTY_CONTROL) => {
|
||
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: typeof EMPTY_CONTROL) => {
|
||
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 [bulkProcessing, setBulkProcessing] = useState(false)
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
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 () => {
|
||
// Load review items from backend
|
||
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) {
|
||
// Split into duplicate suspects vs rule 3 without anchor
|
||
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)
|
||
// Start with duplicates tab if any, otherwise 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])
|
||
}
|
||
}
|
||
|
||
// Loading
|
||
if (loading && 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 (error) {
|
||
return (
|
||
<div className="flex items-center justify-center h-96">
|
||
<p className="text-red-600">{error}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// CREATE/EDIT MODE
|
||
if (mode === 'create') {
|
||
return <ControlForm initial={EMPTY_CONTROL} onSave={handleCreate} onCancel={() => setMode('list')} saving={saving} />
|
||
}
|
||
|
||
if (mode === 'edit' && selectedControl) {
|
||
return (
|
||
<ControlForm
|
||
initial={{
|
||
...EMPTY_CONTROL,
|
||
...selectedControl,
|
||
risk_score: selectedControl.risk_score,
|
||
implementation_effort: selectedControl.implementation_effort,
|
||
open_anchors: selectedControl.open_anchors.length > 0
|
||
? selectedControl.open_anchors
|
||
: [{ framework: '', ref: '', url: '' }],
|
||
requirements: selectedControl.requirements.length > 0 ? selectedControl.requirements : [''],
|
||
test_procedure: selectedControl.test_procedure.length > 0 ? selectedControl.test_procedure : [''],
|
||
evidence: selectedControl.evidence.length > 0 ? selectedControl.evidence : [{ type: '', description: '' }],
|
||
}}
|
||
onSave={handleUpdate}
|
||
onCancel={() => { setMode('detail') }}
|
||
saving={saving}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// V1 COMPARE MODE
|
||
if (compareMode && compareV1Control) {
|
||
return (
|
||
<V1CompareView
|
||
v1Control={compareV1Control}
|
||
matches={compareMatches}
|
||
onBack={() => { setCompareMode(false) }}
|
||
onNavigateToControl={async (controlId: string) => {
|
||
try {
|
||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||
if (res.ok) {
|
||
setCompareMode(false)
|
||
setSelectedControl(await res.json())
|
||
setMode('detail')
|
||
}
|
||
} catch { /* ignore */ }
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// DETAIL MODE
|
||
if (mode === 'detail' && selectedControl) {
|
||
const isDuplicateReview = reviewMode && reviewTab === 'duplicates'
|
||
|
||
// Review tab bar (shown above the detail/compare view in review mode)
|
||
const reviewTabBar = reviewMode ? (
|
||
<div className="border-b border-gray-200 bg-white px-6 py-2 flex items-center gap-4">
|
||
<button
|
||
onClick={() => switchReviewTab('duplicates')}
|
||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${
|
||
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 ({reviewDuplicates.length})
|
||
</button>
|
||
<button
|
||
onClick={() => switchReviewTab('rule3')}
|
||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${
|
||
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 ({reviewRule3.length})
|
||
</button>
|
||
</div>
|
||
) : null
|
||
|
||
if (isDuplicateReview) {
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{reviewTabBar}
|
||
<div className="flex-1 overflow-hidden">
|
||
<ReviewCompare
|
||
ctrl={selectedControl}
|
||
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
||
onReview={handleReview}
|
||
onEdit={() => setMode('edit')}
|
||
reviewIndex={reviewIndex}
|
||
reviewTotal={reviewItems.length}
|
||
onReviewPrev={() => {
|
||
const idx = Math.max(0, reviewIndex - 1)
|
||
setReviewIndex(idx)
|
||
setSelectedControl(reviewItems[idx])
|
||
}}
|
||
onReviewNext={() => {
|
||
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
||
setReviewIndex(idx)
|
||
setSelectedControl(reviewItems[idx])
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{reviewTabBar}
|
||
<div className="flex-1 overflow-hidden">
|
||
<ControlDetail
|
||
ctrl={selectedControl}
|
||
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
||
onEdit={() => setMode('edit')}
|
||
onDelete={handleDelete}
|
||
onReview={handleReview}
|
||
onRefresh={fullReload}
|
||
onCompare={(ctrl, matches) => {
|
||
setCompareV1Control(ctrl)
|
||
setCompareMatches(matches)
|
||
setCompareMode(true)
|
||
}}
|
||
onNavigateToControl={async (controlId: string) => {
|
||
try {
|
||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setSelectedControl(data)
|
||
setMode('detail')
|
||
}
|
||
} catch { /* ignore */ }
|
||
}}
|
||
reviewMode={reviewMode}
|
||
reviewIndex={reviewIndex}
|
||
reviewTotal={reviewItems.length}
|
||
onReviewPrev={() => {
|
||
const idx = Math.max(0, reviewIndex - 1)
|
||
setReviewIndex(idx)
|
||
setSelectedControl(reviewItems[idx])
|
||
}}
|
||
onReviewNext={() => {
|
||
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
||
setReviewIndex(idx)
|
||
setSelectedControl(reviewItems[idx])
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =========================================================================
|
||
// LIST VIEW
|
||
// =========================================================================
|
||
|
||
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={enterReviewMode}
|
||
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={() => handleBulkReject('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) loadProcessedStats() }}
|
||
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={() => setMode('create')}
|
||
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="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={() => { loadControls(); loadMeta(); loadReviewCount() }}
|
||
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</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-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</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>
|
||
<option value="deprecated">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}</option>
|
||
))}
|
||
</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}</option>
|
||
))}
|
||
</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}</option>
|
||
))}
|
||
</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</option>
|
||
<option value="atomic">Atomare Controls</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>
|
||
|
||
{/* 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>
|
||
|
||
{/* Generator Modal */}
|
||
{showGenerator && (
|
||
<GeneratorModal
|
||
onClose={() => setShowGenerator(false)}
|
||
onComplete={() => fullReload()}
|
||
/>
|
||
)}
|
||
|
||
{/* 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={() => handleBulkReject(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) => {
|
||
// Show source group header when sorting by source
|
||
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={() => { setSelectedControl(ctrl); setMode('detail') }}
|
||
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>
|
||
|
||
{/* Open anchors summary + timestamp */}
|
||
<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>
|
||
|
||
{/* Page numbers */}
|
||
{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>
|
||
)
|
||
}
|