diff --git a/admin-compliance/app/sdk/control-library/components/ControlListView.tsx b/admin-compliance/app/sdk/control-library/components/ControlListView.tsx new file mode 100644 index 0000000..719ecb9 --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/ControlListView.tsx @@ -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> + 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 ( +
+ {/* Header */} +
+
+
+ +
+

Canonical Control Library

+

{meta?.total ?? totalCount} Security Controls

+
+
+
+ {reviewCount > 0 && ( + <> + + + + )} + + + +
+
+ + {frameworks.length > 0 && ( +
+
+ + {frameworks[0]?.name} v{frameworks[0]?.version} + + {frameworks[0]?.description} +
+
+ )} + + {/* Filters */} +
+
+
+ + 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" /> +
+ +
+
+ + + + + + + + + + + + | + + +
+
+ + {showStats && processedStats.length > 0 && ( +
+

Verarbeitungsfortschritt

+
+ {processedStats.map((s, i) => ( +
+ {String(s.collection)} +
+ {String(s.processed_chunks)} verarbeitet + {String(s.direct_adopted)} direkt + {String(s.llm_reformed)} reformuliert +
+
+ ))} +
+
+ )} +
+ + {showGenerator && setShowGenerator(false)} onComplete={onFullReload} />} + + {/* Pagination Header */} +
+
+ + {totalCount} Controls gefunden + {totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`} + {loading && Lade...} + + {stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && ( + + )} +
+ Seite {currentPage} von {totalPages} +
+ + {/* Control List */} +
+
+ {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 ( +
+ {showSourceHeader && ( +
+
+ {curSource} +
+
+ )} + +
+ ) + })} + {controls.length === 0 && !loading && ( +
+ {totalCount === 0 && !debouncedSearch && !severityFilter && !domainFilter + ? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.' + : 'Keine Controls gefunden.'} +
+ )} +
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+ + + {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' ? ( + ... + ) : ( + + ) + )} + + +
+ )} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/control-library/components/ControlSimilarControls.tsx b/admin-compliance/app/sdk/control-library/components/ControlSimilarControls.tsx new file mode 100644 index 0000000..391a5ca --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/ControlSimilarControls.tsx @@ -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 | null + similarity_score: number + match_rank: number + match_method: string +} + +interface ControlSimilarControlsProps { + ctrl: CanonicalControl + similarControls: SimilarControl[] + loadingSimilar: boolean + selectedDuplicates: Set + merging: boolean + onToggleDuplicate: (id: string) => void + onMergeDuplicates: () => void +} + +export function ControlSimilarControls({ + ctrl, similarControls, loadingSimilar, selectedDuplicates, merging, onToggleDuplicate, onMergeDuplicates, +}: ControlSimilarControlsProps) { + return ( +
+
+ +

Aehnliche Controls

+ {loadingSimilar && Laden...} +
+ + {similarControls.length > 0 ? ( + <> +
+ + {ctrl.control_id} — {ctrl.title} + Behalten (Haupt-Control) +
+ +
+ {similarControls.map(sim => ( +
+ onToggleDuplicate(sim.control_id)} className="text-red-600" /> + {sim.control_id} + {sim.title} + + {(sim.similarity * 100).toFixed(1)}% + + + +
+ ))} +
+ + {selectedDuplicates.size > 0 && ( + + )} + + ) : ( +

+ {loadingSimilar ? 'Suche aehnliche Controls...' : 'Keine aehnlichen Controls gefunden.'} +

+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/control-library/components/ControlSourceCitation.tsx b/admin-compliance/app/sdk/control-library/components/ControlSourceCitation.tsx new file mode 100644 index 0000000..a1032af --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/ControlSourceCitation.tsx @@ -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 ( +
+
+ +

{sectionTitle}

+ {badgeText && {badgeText}} +
+
+
+ {ctrl.source_citation.source ? ( +

+ {ctrl.source_citation.source} + {ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`} + {ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`} +

+ ) : ctrl.generation_metadata?.source_regulation ? ( +

{String(ctrl.generation_metadata.source_regulation)}

+ ) : null} + {ctrl.source_citation.license &&

Lizenz: {ctrl.source_citation.license}

} + {ctrl.source_citation.license_notice &&

{ctrl.source_citation.license_notice}

} +
+ {ctrl.source_citation.url && ( + + Quelle + + )} +
+ {ctrl.source_original_text && ( +
+ Originaltext anzeigen +

+ {ctrl.source_original_text} +

+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/control-library/components/ControlTraceability.tsx b/admin-compliance/app/sdk/control-library/components/ControlTraceability.tsx new file mode 100644 index 0000000..d9d22eb --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/ControlTraceability.tsx @@ -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 | 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 ( + + ) + } + return {controlId} + } + + return ( + <> + {/* Regulatorische Abdeckung (Eigenentwicklung) covered by parent */} + + {/* Rechtsgrundlagen / Traceability */} + {traceability && traceability.parent_links.length > 0 && ( +
+
+ +

+ Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'}) +

+ + {traceability.regulations_summary?.map(rs => ( + + {rs.regulation_code} + + ))} + {loadingTrace && Laden...} +
+
+ {traceability.parent_links.map((link, i) => ( +
+
+ +
+
+ {link.source_regulation && {link.source_regulation}} + {link.source_article && {link.source_article}} + {!link.source_regulation && link.parent_citation?.source && ( + + {link.parent_citation.source}{link.parent_citation.article && ` — ${link.parent_citation.article}`} + + )} + + {link.link_type === 'decomposition' ? 'Ableitung' : link.link_type === 'dedup_merge' ? 'Dedup' : link.link_type} + +
+

+ via + {link.parent_title && — {link.parent_title}} +

+ {link.obligation && ( +

+ + {link.obligation.normative_strength === 'must' ? 'MUSS' : link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'} + + {link.obligation.text.slice(0, 200)}{link.obligation.text.length > 200 ? '...' : ''} +

+ )} +
+
+
+ ))} +
+
+ )} + + {/* Fallback: simple parent display */} + {ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && ( +
+
+ +

Atomares Control

+ +
+

+ Abgeleitet aus Eltern-Control{' '} + + {ctrl.parent_control_id || ctrl.parent_control_uuid} + + {ctrl.parent_control_title && — {ctrl.parent_control_title}} +

+
+ )} + + {/* Document References */} + {traceability?.is_atomic && traceability.document_references && traceability.document_references.length > 0 && ( +
+
+ +

Original-Dokumente ({traceability.document_references.length})

+
+
+ {traceability.document_references.map((dr, i) => ( +
+ {dr.regulation_code} + {dr.article && {dr.article}} + {dr.paragraph && {dr.paragraph}} + + + {dr.confidence !== null && {(dr.confidence * 100).toFixed(0)}%} + +
+ ))} +
+
+ )} + + {/* Obligations */} + {traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && ( +
+
+ +

+ Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length}) +

+
+
+ {traceability.obligations.map((ob) => ( +
+
+ {ob.candidate_id} + + {ob.normative_strength === 'must' ? 'MUSS' : ob.normative_strength === 'should' ? 'SOLL' : 'KANN'} + + {ob.action && {ob.action}} + {ob.object && → {ob.object}} +
+

+ {ob.obligation_text.slice(0, 300)}{ob.obligation_text.length > 300 ? '...' : ''} +

+
+ ))} +
+
+ )} + + {/* Merged Duplicates */} + {traceability?.merged_duplicates && traceability.merged_duplicates.length > 0 && ( +
+
+ +

+ Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length}) +

+
+
+ {traceability.merged_duplicates.map((dup) => ( +
+ + {dup.title} + {dup.source_regulation && {dup.source_regulation}} +
+ ))} +
+
+ )} + + {/* Child controls */} + {traceability && traceability.children.length > 0 && ( +
+
+ +

Abgeleitete Controls ({traceability.children.length})

+
+
+ {traceability.children.map((child) => ( +
+ + {child.title} + +
+ ))} +
+
+ )} + + ) +} diff --git a/admin-compliance/app/sdk/control-library/components/useControlCRUD.ts b/admin-compliance/app/sdk/control-library/components/useControlCRUD.ts new file mode 100644 index 0000000..8f218bd --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/useControlCRUD.ts @@ -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 + 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 } +} diff --git a/admin-compliance/app/sdk/control-library/components/useControlLibraryState.ts b/admin-compliance/app/sdk/control-library/components/useControlLibraryState.ts new file mode 100644 index 0000000..c9e7111 --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/useControlLibraryState.ts @@ -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 + verification_method_counts?: Record + category_counts?: Record + evidence_type_counts?: Record + release_state_counts?: Record +} + +const PAGE_SIZE = 50 + +export function useControlLibraryState() { + const [frameworks, setFrameworks] = useState([]) + const [controls, setControls] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [meta, setMeta] = useState(null) + const [selectedControl, setSelectedControl] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Filters + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [severityFilter, setSeverityFilter] = useState('') + const [domainFilter, setDomainFilter] = useState('') + const [stateFilter, setStateFilter] = useState('') + const [verificationFilter, setVerificationFilter] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [evidenceTypeFilter, setEvidenceTypeFilter] = useState('') + const [audienceFilter, setAudienceFilter] = useState('') + const [sourceFilter, setSourceFilter] = useState('') + const [typeFilter, setTypeFilter] = useState('') + 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>>([]) + 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([]) + const [reviewCount, setReviewCount] = useState(0) + const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates') + const [reviewDuplicates, setReviewDuplicates] = useState([]) + const [reviewRule3, setReviewRule3] = useState([]) + + // V1 Compare state + const [compareMode, setCompareMode] = useState(false) + const [compareV1Control, setCompareV1Control] = useState(null) + const [compareMatches, setCompareMatches] = useState | null + similarity_score: number; match_rank: number; match_method: string + }>>([]) + + const metaAbortRef = useRef(null) + const controlsAbortRef = useRef(null) + const searchTimer = useRef | 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) => { + 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, + } +} diff --git a/admin-compliance/app/sdk/control-library/page.tsx b/admin-compliance/app/sdk/control-library/page.tsx index 315fa5f..7bc513b 100644 --- a/admin-compliance/app/sdk/control-library/page.tsx +++ b/admin-compliance/app/sdk/control-library/page.tsx @@ -1,406 +1,40 @@ '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 { 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' - -// ============================================================================= -// TYPES -// ============================================================================= - -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 - verification_method_counts?: Record - category_counts?: Record - evidence_type_counts?: Record - release_state_counts?: Record -} - -// ============================================================================= -// CONTROL LIBRARY PAGE — Server-Side Pagination -// ============================================================================= - -const PAGE_SIZE = 50 +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 [frameworks, setFrameworks] = useState([]) - const [controls, setControls] = useState([]) - const [totalCount, setTotalCount] = useState(0) - const [meta, setMeta] = useState(null) - const [selectedControl, setSelectedControl] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const state = useControlLibraryState() - // Filters - const [searchQuery, setSearchQuery] = useState('') - const [debouncedSearch, setDebouncedSearch] = useState('') - const [severityFilter, setSeverityFilter] = useState('') - const [domainFilter, setDomainFilter] = useState('') - const [stateFilter, setStateFilter] = useState('') - const [verificationFilter, setVerificationFilter] = useState('') - const [categoryFilter, setCategoryFilter] = useState('') - const [evidenceTypeFilter, setEvidenceTypeFilter] = useState('') - const [audienceFilter, setAudienceFilter] = useState('') - const [sourceFilter, setSourceFilter] = useState('') - const [typeFilter, setTypeFilter] = useState('') - const [hideDuplicates, setHideDuplicates] = useState(true) - const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id') + 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, + }) - // 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>>([]) - 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([]) - const [reviewCount, setReviewCount] = useState(0) - const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates') - const [reviewDuplicates, setReviewDuplicates] = useState([]) - const [reviewRule3, setReviewRule3] = useState([]) - - // V1 Compare mode - const [compareMode, setCompareMode] = useState(false) - const [compareV1Control, setCompareV1Control] = useState(null) - const [compareMatches, setCompareMatches] = useState | null - similarity_score: number; match_rank: number; match_method: string - }>>([]) - - // Abort controllers for cancelling stale requests - const metaAbortRef = useRef(null) - const controlsAbortRef = useRef(null) - - // Debounce search - const searchTimer = useRef | 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) => { - 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 frameworks (once) - const loadFrameworks = useCallback(async () => { - try { - const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`) - if (res.ok) setFrameworks(await res.json()) - } catch { /* ignore */ } - }, []) - - // Load faceted metadata (reloads when filters change, cancels stale requests) - 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]) - - // Load controls page (cancels stale requests) - const loadControls = useCallback(async () => { - if (controlsAbortRef.current) controlsAbortRef.current.abort() - const controller = new AbortController() - controlsAbortRef.current = controller - 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}`, { 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]) - - // 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 (frameworks only once) - useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount]) - - // Load faceted meta when filters change - useEffect(() => { loadMeta() }, [loadMeta]) - - // 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(), loadFrameworks(), loadReviewCount()]) - }, [loadControls, loadMeta, loadFrameworks, loadReviewCount]) - - // CRUD handlers - 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 [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) { + // Loading / error screens + if (state.loading && state.controls.length === 0) { return (
@@ -408,114 +42,110 @@ export default function ControlLibraryPage() { ) } - if (error) { + if (state.error) { return (
-

{error}

+

{state.error}

) } - // CREATE/EDIT MODE - if (mode === 'create') { - return setMode('list')} saving={saving} /> + // CREATE mode + if (state.mode === 'create') { + return state.setMode('list')} saving={state.saving} /> } - if (mode === 'edit' && selectedControl) { + // EDIT mode + if (state.mode === 'edit' && state.selectedControl) { return ( 0 - ? selectedControl.open_anchors + ...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: 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: '' }], + 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={handleUpdate} - onCancel={() => { setMode('detail') }} - saving={saving} + onCancel={() => state.setMode('detail')} + saving={state.saving} /> ) } - // V1 COMPARE MODE - if (compareMode && compareV1Control) { + // V1 COMPARE mode + if (state.compareMode && state.compareV1Control) { return ( { 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) { - setCompareMode(false) - setSelectedControl(await res.json()) - setMode('detail') - } + if (res.ok) { state.setCompareMode(false); state.setSelectedControl(await res.json()); state.setMode('detail') } } catch { /* ignore */ } }} /> ) } - // DETAIL MODE - if (mode === 'detail' && selectedControl) { - const isDuplicateReview = reviewMode && reviewTab === 'duplicates' + // DETAIL mode + if (state.mode === 'detail' && state.selectedControl) { + const isDuplicateReview = state.reviewMode && state.reviewTab === 'duplicates' - // Review tab bar (shown above the detail/compare view in review mode) - const reviewTabBar = reviewMode ? ( + const reviewTabBar = state.reviewMode ? (
) : 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 (
{reviewTabBar}
{ setMode('list'); setSelectedControl(null); setReviewMode(false) }} + ctrl={state.selectedControl} + onBack={() => { state.setMode('list'); state.setSelectedControl(null); state.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]) - }} + onEdit={() => state.setMode('edit')} + reviewIndex={state.reviewIndex} + reviewTotal={state.reviewItems.length} + onReviewPrev={reviewNavProps.onReviewPrev} + onReviewNext={reviewNavProps.onReviewNext} />
@@ -527,471 +157,79 @@ export default function ControlLibraryPage() { {reviewTabBar}
{ setMode('list'); setSelectedControl(null); setReviewMode(false) }} - onEdit={() => setMode('edit')} + ctrl={state.selectedControl} + onBack={() => { state.setMode('list'); state.setSelectedControl(null); state.setReviewMode(false) }} + onEdit={() => state.setMode('edit')} onDelete={handleDelete} onReview={handleReview} - onRefresh={fullReload} - onCompare={(ctrl, matches) => { - setCompareV1Control(ctrl) - setCompareMatches(matches) - setCompareMode(true) - }} + 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) { - const data = await res.json() - setSelectedControl(data) - setMode('detail') - } + if (res.ok) { state.setSelectedControl(await res.json()); state.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]) - }} + reviewMode={state.reviewMode} + reviewIndex={state.reviewIndex} + reviewTotal={state.reviewItems.length} + onReviewPrev={reviewNavProps.onReviewPrev} + onReviewNext={reviewNavProps.onReviewNext} />
) } - // LIST VIEW + // LIST mode return ( -
- {/* Header */} -
-
-
- -
-

Canonical Control Library

-

- {meta?.total ?? totalCount} Security Controls -

-
-
-
- {reviewCount > 0 && ( - <> - - - - )} - - - -
-
- - {/* Frameworks */} - {frameworks.length > 0 && ( -
-
- - {frameworks[0]?.name} v{frameworks[0]?.version} - - {frameworks[0]?.description} -
-
- )} - - {/* Filters */} -
-
-
- - 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" - /> -
- -
-
- - - - - - - - - - - - | - - -
-
- - {/* Processing Stats */} - {showStats && processedStats.length > 0 && ( -
-

Verarbeitungsfortschritt

-
- {processedStats.map((s, i) => ( -
- {String(s.collection)} -
- {String(s.processed_chunks)} verarbeitet - {String(s.direct_adopted)} direkt - {String(s.llm_reformed)} reformuliert -
-
- ))} -
-
- )} -
- - {/* Generator Modal */} - {showGenerator && ( - setShowGenerator(false)} - onComplete={() => fullReload()} - /> - )} - - {/* Pagination Header */} -
-
- - {totalCount} Controls gefunden - {totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`} - {loading && Lade...} - - {stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && ( - - )} -
- Seite {currentPage} von {totalPages} -
- - {/* Control List */} -
-
- {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 ( -
- {showSourceHeader && ( -
-
- {curSource} -
-
- )} - -
- ) - })} - - {controls.length === 0 && !loading && ( -
- {totalCount === 0 && !debouncedSearch && !severityFilter && !domainFilter - ? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.' - : 'Keine Controls gefunden.'} -
- )} -
- - {/* Pagination Controls */} - {totalPages > 1 && ( -
- - - - {/* 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' ? ( - ... - ) : ( - - ) - ) - } - - - -
- )} -
-
+ { 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} + /> ) } diff --git a/admin-compliance/app/sdk/controls/page.tsx b/admin-compliance/app/sdk/controls/page.tsx index 24ef224..5053d23 100644 --- a/admin-compliance/app/sdk/controls/page.tsx +++ b/admin-compliance/app/sdk/controls/page.tsx @@ -3,371 +3,19 @@ import React, { useState } from 'react' import { useRouter } from 'next/navigation' import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader' +import { mapControlTypeToDisplay, mapStatusToDisplay, DisplayControl } from './_types' +import { ControlCard } from './_components/ControlCard' +import { AddControlForm } from './_components/AddControlForm' +import { LoadingSkeleton } from './_components/LoadingSkeleton' +import { StatsCards } from './_components/StatsCards' +import { FilterBar } from './_components/FilterBar' +import { RAGPanel } from './_components/RAGPanel' +import { useControlsData } from './_hooks/useControlsData' +import { useRAGSuggestions } from './_hooks/useRAGSuggestions' -// ============================================================================= -// TYPES -// ============================================================================= - -type DisplayControlType = 'preventive' | 'detective' | 'corrective' -type DisplayCategory = 'technical' | 'organizational' | 'physical' -type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented' - -interface DisplayControl { - id: string - name: string - description: string - type: ControlType - category: string - implementationStatus: ImplementationStatus - evidence: string[] - owner: string | null - dueDate: Date | null - code: string - displayType: DisplayControlType - displayCategory: DisplayCategory - displayStatus: DisplayStatus - effectivenessPercent: number - linkedRequirements: string[] - linkedEvidence: { id: string; title: string; status: string }[] - lastReview: Date -} - -// ============================================================================= -// HELPER FUNCTIONS -// ============================================================================= - -function mapControlTypeToDisplay(type: ControlType): DisplayCategory { - switch (type) { - case 'TECHNICAL': return 'technical' - case 'ORGANIZATIONAL': return 'organizational' - case 'PHYSICAL': return 'physical' - default: return 'technical' - } -} - -function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus { - switch (status) { - case 'IMPLEMENTED': return 'implemented' - case 'PARTIAL': return 'partial' - case 'NOT_IMPLEMENTED': return 'not-implemented' - default: return 'not-implemented' - } -} - - -// ============================================================================= -// COMPONENTS -// ============================================================================= - -function ControlCard({ - control, - onStatusChange, - onEffectivenessChange, - onLinkEvidence, -}: { - control: DisplayControl - onStatusChange: (status: ImplementationStatus) => void - onEffectivenessChange: (effectivenessPercent: number) => void - onLinkEvidence: () => void -}) { - const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false) - - const typeColors = { - preventive: 'bg-blue-100 text-blue-700', - detective: 'bg-purple-100 text-purple-700', - corrective: 'bg-orange-100 text-orange-700', - } - - const categoryColors = { - technical: 'bg-green-100 text-green-700', - organizational: 'bg-yellow-100 text-yellow-700', - physical: 'bg-gray-100 text-gray-700', - } - - const statusColors = { - implemented: 'border-green-200 bg-green-50', - partial: 'border-yellow-200 bg-yellow-50', - planned: 'border-blue-200 bg-blue-50', - 'not-implemented': 'border-red-200 bg-red-50', - } - - const statusLabels = { - implemented: 'Implementiert', - partial: 'Teilweise', - planned: 'Geplant', - 'not-implemented': 'Nicht implementiert', - } - - return ( -
-
-
-
- - {control.code} - - - {control.displayType === 'preventive' ? 'Praeventiv' : - control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'} - - - {control.displayCategory === 'technical' ? 'Technisch' : - control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'} - -
-

{control.name}

-

{control.description}

-
- -
- -
-
setShowEffectivenessSlider(!showEffectivenessSlider)} - > - Wirksamkeit - {control.effectivenessPercent}% -
-
-
= 80 ? 'bg-green-500' : - control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500' - }`} - style={{ width: `${control.effectivenessPercent}%` }} - /> -
- {showEffectivenessSlider && ( -
- onEffectivenessChange(Number(e.target.value))} - className="w-full" - /> -
- )} -
- -
-
- Verantwortlich: - {control.owner || 'Nicht zugewiesen'} -
-
- Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')} -
-
- -
-
- {control.linkedRequirements.slice(0, 3).map(req => ( - - {req} - - ))} - {control.linkedRequirements.length > 3 && ( - - +{control.linkedRequirements.length - 3} - - )} -
- - {statusLabels[control.displayStatus]} - -
- - {/* Linked Evidence */} - {control.linkedEvidence.length > 0 && ( -
- - Nachweise: {control.linkedEvidence.length} - {(() => { - const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) => - ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel) - ).length - return e2plus > 0 ? ` (${e2plus} E2+)` : '' - })()} - -
- {control.linkedEvidence.map(ev => ( - - {ev.title} - {(ev as { confidenceLevel?: string }).confidenceLevel && ( - ({(ev as { confidenceLevel?: string }).confidenceLevel}) - )} - - ))} -
-
- )} - -
- -
-
- ) -} - -function AddControlForm({ - onSubmit, - onCancel, -}: { - onSubmit: (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => void - onCancel: () => void -}) { - const [formData, setFormData] = useState({ - name: '', - description: '', - type: 'TECHNICAL' as ControlType, - category: '', - owner: '', - }) - - return ( -
-

Neue Kontrolle

-
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="z.B. Zugriffskontrolle" - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" - /> -
-
- -