Files
breakpilot-compliance/admin-compliance/app/sdk/control-library/page.tsx
Benjamin Admin db7c207464
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
feat: V1 Control Enrichment — Eigenentwicklung-Label, regulatorisches Matching & Vergleichsansicht
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>
2026-03-26 10:32:08 +01:00

964 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { 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>
)
}