refactor(admin): split control-library, iace/mitigations, iace/components, controls pages
All 4 page.tsx files reduced well below 500 LOC (235/181/158/262) by extracting components and hooks into colocated _components/ and _hooks/ subdirectories. Zero behavior changes — logic relocated verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, Search, ChevronRight, ChevronLeft, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3, ListChecks, Trash2,
|
||||
ChevronsLeft, ChevronsRight, ArrowUpDown, Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, Framework,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge, ObligationTypeBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||
} from './helpers'
|
||||
import { ControlsMeta } from './useControlLibraryState'
|
||||
import { GeneratorModal } from './GeneratorModal'
|
||||
|
||||
interface ControlListViewProps {
|
||||
frameworks: Framework[]
|
||||
controls: CanonicalControl[]
|
||||
totalCount: number
|
||||
meta: ControlsMeta | null
|
||||
loading: boolean
|
||||
reviewCount: number
|
||||
bulkProcessing: boolean
|
||||
showStats: boolean
|
||||
processedStats: Array<Record<string, unknown>>
|
||||
showGenerator: boolean
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
sortBy: 'id' | 'newest' | 'oldest' | 'source'
|
||||
// Filter values
|
||||
searchQuery: string
|
||||
severityFilter: string
|
||||
domainFilter: string
|
||||
stateFilter: string
|
||||
verificationFilter: string
|
||||
categoryFilter: string
|
||||
evidenceTypeFilter: string
|
||||
audienceFilter: string
|
||||
sourceFilter: string
|
||||
typeFilter: string
|
||||
hideDuplicates: boolean
|
||||
// Setters
|
||||
setSearchQuery: (v: string) => void
|
||||
setSeverityFilter: (v: string) => void
|
||||
setDomainFilter: (v: string) => void
|
||||
setStateFilter: (v: string) => void
|
||||
setVerificationFilter: (v: string) => void
|
||||
setCategoryFilter: (v: string) => void
|
||||
setEvidenceTypeFilter: (v: string) => void
|
||||
setAudienceFilter: (v: string) => void
|
||||
setSourceFilter: (v: string) => void
|
||||
setTypeFilter: (v: string) => void
|
||||
setHideDuplicates: (v: boolean) => void
|
||||
setSortBy: (v: 'id' | 'newest' | 'oldest' | 'source') => void
|
||||
setShowStats: (v: boolean) => void
|
||||
setShowGenerator: (v: boolean) => void
|
||||
setCurrentPage: (v: number | ((p: number) => number)) => void
|
||||
// Actions
|
||||
onSelectControl: (c: CanonicalControl) => void
|
||||
onCreateMode: () => void
|
||||
onEnterReview: () => void
|
||||
onBulkReject: (state: string) => void
|
||||
onRefresh: () => void
|
||||
onLoadStats: () => void
|
||||
onFullReload: () => void
|
||||
}
|
||||
|
||||
export function ControlListView({
|
||||
frameworks, controls, totalCount, meta, loading,
|
||||
reviewCount, bulkProcessing, showStats, processedStats,
|
||||
showGenerator, currentPage, totalPages, sortBy,
|
||||
searchQuery, severityFilter, domainFilter, stateFilter,
|
||||
verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter,
|
||||
sourceFilter, typeFilter, hideDuplicates,
|
||||
setSearchQuery, setSeverityFilter, setDomainFilter, setStateFilter,
|
||||
setVerificationFilter, setCategoryFilter, setEvidenceTypeFilter, setAudienceFilter,
|
||||
setSourceFilter, setTypeFilter, setHideDuplicates, setSortBy,
|
||||
setShowStats, setShowGenerator, setCurrentPage,
|
||||
onSelectControl, onCreateMode, onEnterReview, onBulkReject, onRefresh, onLoadStats, onFullReload,
|
||||
}: ControlListViewProps) {
|
||||
const debouncedSearch = searchQuery // used for empty state message
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
|
||||
<p className="text-xs text-gray-500">{meta?.total ?? totalCount} Security Controls</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{reviewCount > 0 && (
|
||||
<>
|
||||
<button onClick={onEnterReview} className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Review ({reviewCount})
|
||||
</button>
|
||||
<button onClick={() => onBulkReject('needs_review')} disabled={bulkProcessing}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{bulkProcessing ? 'Wird verarbeitet...' : `Alle ${reviewCount} ablehnen`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => { setShowStats(!showStats); if (!showStats) onLoadStats() }}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<BarChart3 className="w-4 h-4" />Stats
|
||||
</button>
|
||||
<button onClick={() => setShowGenerator(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700">
|
||||
<Zap className="w-4 h-4" />Generator
|
||||
</button>
|
||||
<button onClick={onCreateMode}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<Plus className="w-4 h-4" />Neues Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{frameworks.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-700">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
|
||||
<span className="text-purple-500">—</span>
|
||||
<span>{frameworks[0]?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input type="text" placeholder="Controls durchsuchen (ID, Titel, Objective)..."
|
||||
value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500" />
|
||||
</div>
|
||||
<button onClick={onRefresh} className="p-2 text-gray-400 hover:text-purple-600" title="Aktualisieren">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select value={severityFilter} onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch{meta?.severity_counts?.critical ? ` (${meta.severity_counts.critical})` : ''}</option>
|
||||
<option value="high">Hoch{meta?.severity_counts?.high ? ` (${meta.severity_counts.high})` : ''}</option>
|
||||
<option value="medium">Mittel{meta?.severity_counts?.medium ? ` (${meta.severity_counts.medium})` : ''}</option>
|
||||
<option value="low">Niedrig{meta?.severity_counts?.low ? ` (${meta.severity_counts.low})` : ''}</option>
|
||||
</select>
|
||||
<select value={domainFilter} onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Domain</option>
|
||||
{(meta?.domains || []).map(d => <option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>)}
|
||||
</select>
|
||||
<select value={stateFilter} onChange={e => setStateFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Status</option>
|
||||
<option value="draft">Draft{meta?.release_state_counts?.draft ? ` (${meta.release_state_counts.draft})` : ''}</option>
|
||||
<option value="approved">Approved{meta?.release_state_counts?.approved ? ` (${meta.release_state_counts.approved})` : ''}</option>
|
||||
<option value="needs_review">Review noetig{meta?.release_state_counts?.needs_review ? ` (${meta.release_state_counts.needs_review})` : ''}</option>
|
||||
<option value="too_close">Zu aehnlich{meta?.release_state_counts?.too_close ? ` (${meta.release_state_counts.too_close})` : ''}</option>
|
||||
<option value="duplicate">Duplikat{meta?.release_state_counts?.duplicate ? ` (${meta.release_state_counts.duplicate})` : ''}</option>
|
||||
<option value="deprecated">Deprecated{meta?.release_state_counts?.deprecated ? ` (${meta.release_state_counts.deprecated})` : ''}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap">
|
||||
<input type="checkbox" checked={hideDuplicates} onChange={e => setHideDuplicates(e.target.checked)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
Duplikate ausblenden
|
||||
</label>
|
||||
<select value={verificationFilter} onChange={e => setVerificationFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Nachweis</option>
|
||||
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
|
||||
))}
|
||||
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={categoryFilter} onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Kategorie</option>
|
||||
{CATEGORY_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>)}
|
||||
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={evidenceTypeFilter} onChange={e => setEvidenceTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Nachweisart</option>
|
||||
{EVIDENCE_TYPE_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option>)}
|
||||
{meta?.evidence_type_counts?.['__none__'] ? <option value="__none__">Ohne Nachweisart ({meta.evidence_type_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select value={audienceFilter} onChange={e => setAudienceFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Zielgruppe</option>
|
||||
<option value="unternehmen">Unternehmen</option>
|
||||
<option value="behoerden">Behoerden</option>
|
||||
<option value="entwickler">Entwickler</option>
|
||||
<option value="datenschutzbeauftragte">DSB</option>
|
||||
<option value="geschaeftsfuehrung">Geschaeftsfuehrung</option>
|
||||
<option value="it-abteilung">IT-Abteilung</option>
|
||||
<option value="rechtsabteilung">Rechtsabteilung</option>
|
||||
<option value="compliance-officer">Compliance Officer</option>
|
||||
<option value="personalwesen">Personalwesen</option>
|
||||
<option value="einkauf">Einkauf</option>
|
||||
<option value="produktion">Produktion</option>
|
||||
<option value="vertrieb">Vertrieb</option>
|
||||
<option value="gesundheitswesen">Gesundheitswesen</option>
|
||||
<option value="finanzwesen">Finanzwesen</option>
|
||||
<option value="oeffentlicher_dienst">Oeffentl. Dienst</option>
|
||||
</select>
|
||||
<select value={sourceFilter} onChange={e => setSourceFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500 max-w-[220px]">
|
||||
<option value="">Dokumentenursprung</option>
|
||||
{meta && <option value="__none__">Ohne Quelle ({meta.no_source_count})</option>}
|
||||
{(meta?.sources || []).map(s => <option key={s.source} value={s.source}>{s.source} ({s.count})</option>)}
|
||||
</select>
|
||||
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="rich">Rich Controls{meta?.type_counts ? ` (${meta.type_counts.rich})` : ''}</option>
|
||||
<option value="atomic">Atomare Controls{meta?.type_counts ? ` (${meta.type_counts.atomic})` : ''}</option>
|
||||
<option value="eigenentwicklung">Eigenentwicklung{meta?.type_counts ? ` (${meta.type_counts.eigenentwicklung})` : ''}</option>
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest' | 'source')}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="id">Sortierung: ID</option>
|
||||
<option value="source">Nach Quelle</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Aelteste zuerst</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showStats && processedStats.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-2">Verarbeitungsfortschritt</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{processedStats.map((s, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<span className="font-medium text-gray-700">{String(s.collection)}</span>
|
||||
<div className="flex gap-2 mt-1 text-gray-500">
|
||||
<span>{String(s.processed_chunks)} verarbeitet</span>
|
||||
<span>{String(s.direct_adopted)} direkt</span>
|
||||
<span>{String(s.llm_reformed)} reformuliert</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showGenerator && <GeneratorModal onClose={() => setShowGenerator(false)} onComplete={onFullReload} />}
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`}
|
||||
{loading && <span className="ml-2 text-purple-500">Lade...</span>}
|
||||
</span>
|
||||
{stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && (
|
||||
<button onClick={() => onBulkReject(stateFilter)} disabled={bulkProcessing}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-white bg-red-600 rounded hover:bg-red-700 disabled:opacity-50">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{bulkProcessing ? '...' : `Alle ${totalCount} ablehnen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{controls.map((ctrl, idx) => {
|
||||
const prevSource = idx > 0 ? (controls[idx - 1].source_citation?.source || 'Ohne Quelle') : null
|
||||
const curSource = ctrl.source_citation?.source || 'Ohne Quelle'
|
||||
const showSourceHeader = sortBy === 'source' && curSource !== prevSource
|
||||
return (
|
||||
<div key={ctrl.control_id}>
|
||||
{showSourceHeader && (
|
||||
<div className="flex items-center gap-2 pt-3 pb-1">
|
||||
<div className="h-px flex-1 bg-blue-200" />
|
||||
<span className="text-xs font-semibold text-blue-700 bg-blue-50 px-2 py-0.5 rounded whitespace-nowrap">{curSource}</span>
|
||||
<div className="h-px flex-1 bg-blue-200" />
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => onSelectControl(ctrl)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{ctrl.risk_score !== null && <span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<BookOpen className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-700">{ctrl.open_anchors.length} Referenzen</span>
|
||||
{ctrl.source_citation?.source && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="text-xs text-blue-600">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-300">|</span>
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
||||
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '–'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{controls.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
{totalCount === 0 && !debouncedSearch && !severityFilter && !domainFilter
|
||||
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
|
||||
: 'Keine Controls gefunden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Erste Seite">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Vorherige Seite">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||
acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === 'dots' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||
) : (
|
||||
<button key={p} onClick={() => setCurrentPage(p as number)}
|
||||
className={`w-8 h-8 text-sm rounded-lg ${currentPage === p ? 'bg-purple-600 text-white' : 'text-gray-600 hover:bg-purple-50 hover:text-purple-600'}`}>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Naechste Seite">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 disabled:opacity-30 disabled:cursor-not-allowed" title="Letzte Seite">
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { Search, GitMerge } from 'lucide-react'
|
||||
import {
|
||||
LicenseRuleBadge, VerificationMethodBadge, type CanonicalControl,
|
||||
} from './helpers'
|
||||
|
||||
interface SimilarControl {
|
||||
control_id: string
|
||||
title: string
|
||||
severity: string
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule: number | null
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface ControlSimilarControlsProps {
|
||||
ctrl: CanonicalControl
|
||||
similarControls: SimilarControl[]
|
||||
loadingSimilar: boolean
|
||||
selectedDuplicates: Set<string>
|
||||
merging: boolean
|
||||
onToggleDuplicate: (id: string) => void
|
||||
onMergeDuplicates: () => void
|
||||
}
|
||||
|
||||
export function ControlSimilarControls({
|
||||
ctrl, similarControls, loadingSimilar, selectedDuplicates, merging, onToggleDuplicate, onMergeDuplicates,
|
||||
}: ControlSimilarControlsProps) {
|
||||
return (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Search className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Aehnliche Controls</h3>
|
||||
{loadingSimilar && <span className="text-xs text-gray-400">Laden...</span>}
|
||||
</div>
|
||||
|
||||
{similarControls.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-3 p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input type="radio" checked readOnly className="text-purple-600" />
|
||||
<span className="text-sm font-medium text-purple-700">{ctrl.control_id} — {ctrl.title}</span>
|
||||
<span className="text-xs text-gray-400 ml-auto">Behalten (Haupt-Control)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarControls.map(sim => (
|
||||
<div key={sim.control_id} className="p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
||||
<input type="checkbox" checked={selectedDuplicates.has(sim.control_id)}
|
||||
onChange={() => onToggleDuplicate(sim.control_id)} className="text-red-600" />
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{sim.control_id}</span>
|
||||
<span className="text-sm text-gray-700 flex-1">{sim.title}</span>
|
||||
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
|
||||
{(sim.similarity * 100).toFixed(1)}%
|
||||
</span>
|
||||
<LicenseRuleBadge rule={sim.license_rule} />
|
||||
<VerificationMethodBadge method={sim.verification_method} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedDuplicates.size > 0 && (
|
||||
<button onClick={onMergeDuplicates} disabled={merging}
|
||||
className="mt-3 flex items-center gap-1.5 px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<GitMerge className="w-3.5 h-3.5" />
|
||||
{merging ? 'Zusammenfuehren...' : `${selectedDuplicates.size} Duplikat(e) zusammenfuehren`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
{loadingSimilar ? 'Suche aehnliche Controls...' : 'Keine aehnlichen Controls gefunden.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { ExternalLink, Scale } from 'lucide-react'
|
||||
import type { CanonicalControl } from './helpers'
|
||||
|
||||
export function ControlSourceCitation({ ctrl }: { ctrl: CanonicalControl }) {
|
||||
if (!ctrl.source_citation) return null
|
||||
|
||||
const stype = ctrl.source_citation.source_type
|
||||
const colorSet = stype === 'law'
|
||||
? { bg: 'bg-blue-50 border-blue-200', icon: 'text-blue-600', title: 'text-blue-900', badge: 'bg-blue-100 text-blue-700' }
|
||||
: stype === 'guideline'
|
||||
? { bg: 'bg-indigo-50 border-indigo-200', icon: 'text-indigo-600', title: 'text-indigo-900', badge: 'bg-indigo-100 text-indigo-700' }
|
||||
: { bg: 'bg-teal-50 border-teal-200', icon: 'text-teal-600', title: 'text-teal-900', badge: 'bg-teal-100 text-teal-700' }
|
||||
|
||||
const sectionTitle = stype === 'law' ? 'Gesetzliche Grundlage'
|
||||
: stype === 'guideline' ? 'Behoerdliche Leitlinie'
|
||||
: 'Standard / Best Practice'
|
||||
|
||||
const badgeText = stype === 'law' ? 'Direkte gesetzliche Pflicht'
|
||||
: stype === 'guideline' ? 'Aufsichtsbehoerdliche Empfehlung'
|
||||
: stype === 'standard' || (!stype && ctrl.license_rule === 2) ? 'Freiwilliger Standard'
|
||||
: !stype && ctrl.license_rule === 1 ? 'Noch nicht klassifiziert' : null
|
||||
|
||||
return (
|
||||
<section className={`border rounded-lg p-4 ${colorSet.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className={`w-4 h-4 ${colorSet.icon}`} />
|
||||
<h3 className={`text-sm font-semibold ${colorSet.title}`}>{sectionTitle}</h3>
|
||||
{badgeText && <span className={`text-xs px-2 py-0.5 rounded-full ${colorSet.badge}`}>{badgeText}</span>}
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
{ctrl.source_citation.source ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</p>
|
||||
) : ctrl.generation_metadata?.source_regulation ? (
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">{String(ctrl.generation_metadata.source_regulation)}</p>
|
||||
) : null}
|
||||
{ctrl.source_citation.license && <p className="text-xs text-blue-600">Lizenz: {ctrl.source_citation.license}</p>}
|
||||
{ctrl.source_citation.license_notice && <p className="text-xs text-blue-600 mt-0.5">{ctrl.source_citation.license_notice}</p>}
|
||||
</div>
|
||||
{ctrl.source_citation.url && (
|
||||
<a href={ctrl.source_citation.url} target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 whitespace-nowrap">
|
||||
<ExternalLink className="w-3.5 h-3.5" />Quelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="text-xs text-gray-600 mt-2 p-2 bg-white rounded border border-blue-100 leading-relaxed max-h-40 overflow-y-auto whitespace-pre-wrap">
|
||||
{ctrl.source_original_text}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { Scale, Landmark, GitMerge, FileText } from 'lucide-react'
|
||||
import {
|
||||
SeverityBadge, ObligationTypeBadge, ExtractionMethodBadge,
|
||||
type CanonicalControl, type ObligationInfo, type DocumentReference, type MergedDuplicate, type RegulationSummary,
|
||||
} from './helpers'
|
||||
|
||||
interface TraceabilityData {
|
||||
control_id: string
|
||||
title: string
|
||||
is_atomic: boolean
|
||||
parent_links: Array<{
|
||||
parent_control_id: string
|
||||
parent_title: string
|
||||
link_type: string
|
||||
confidence: number
|
||||
source_regulation: string | null
|
||||
source_article: string | null
|
||||
parent_citation: Record<string, string> | null
|
||||
obligation: {
|
||||
text: string; action: string; object: string; normative_strength: string
|
||||
} | null
|
||||
}>
|
||||
children: Array<{
|
||||
control_id: string; title: string; category: string; severity: string; decomposition_method: string
|
||||
}>
|
||||
source_count: number
|
||||
obligations?: ObligationInfo[]
|
||||
obligation_count?: number
|
||||
document_references?: DocumentReference[]
|
||||
merged_duplicates?: MergedDuplicate[]
|
||||
merged_duplicates_count?: number
|
||||
regulations_summary?: RegulationSummary[]
|
||||
}
|
||||
|
||||
interface ControlTraceabilityProps {
|
||||
ctrl: CanonicalControl
|
||||
traceability: TraceabilityData | null
|
||||
loadingTrace: boolean
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
}
|
||||
|
||||
export function ControlTraceability({ ctrl, traceability, loadingTrace, onNavigateToControl }: ControlTraceabilityProps) {
|
||||
const ControlLink = ({ controlId }: { controlId: string }) => {
|
||||
if (onNavigateToControl) {
|
||||
return (
|
||||
<button onClick={() => onNavigateToControl(controlId)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline">
|
||||
{controlId}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{controlId}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Regulatorische Abdeckung (Eigenentwicklung) covered by parent */}
|
||||
|
||||
{/* Rechtsgrundlagen / Traceability */}
|
||||
{traceability && traceability.parent_links.length > 0 && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Landmark className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">
|
||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
||||
</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{traceability.regulations_summary?.map(rs => (
|
||||
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
|
||||
{rs.regulation_code}
|
||||
</span>
|
||||
))}
|
||||
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{traceability.parent_links.map((link, i) => (
|
||||
<div key={i} className="bg-white/60 border border-violet-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Scale className="w-4 h-4 text-violet-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{link.source_regulation && <span className="text-sm font-semibold text-violet-900">{link.source_regulation}</span>}
|
||||
{link.source_article && <span className="text-sm text-violet-700">{link.source_article}</span>}
|
||||
{!link.source_regulation && link.parent_citation?.source && (
|
||||
<span className="text-sm font-semibold text-violet-900">
|
||||
{link.parent_citation.source}{link.parent_citation.article && ` — ${link.parent_citation.article}`}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
link.link_type === 'decomposition' ? 'bg-violet-100 text-violet-600' :
|
||||
link.link_type === 'dedup_merge' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{link.link_type === 'decomposition' ? 'Ableitung' : link.link_type === 'dedup_merge' ? 'Dedup' : link.link_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-violet-600 mt-1">
|
||||
via <ControlLink controlId={link.parent_control_id} />
|
||||
{link.parent_title && <span className="text-violet-500 ml-1">— {link.parent_title}</span>}
|
||||
</p>
|
||||
{link.obligation && (
|
||||
<p className="text-xs text-violet-500 mt-1.5 bg-violet-50 rounded p-2">
|
||||
<span className={`inline-block mr-1.5 px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
link.obligation.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
link.obligation.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{link.obligation.normative_strength === 'must' ? 'MUSS' : link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{link.obligation.text.slice(0, 200)}{link.obligation.text.length > 200 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Fallback: simple parent display */}
|
||||
{ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<GitMerge className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">Atomares Control</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<p className="text-sm text-violet-800">
|
||||
Abgeleitet aus Eltern-Control{' '}
|
||||
<span className="font-mono font-semibold text-purple-700 bg-purple-100 px-1.5 py-0.5 rounded">
|
||||
{ctrl.parent_control_id || ctrl.parent_control_uuid}
|
||||
</span>
|
||||
{ctrl.parent_control_title && <span className="text-violet-700 ml-1">— {ctrl.parent_control_title}</span>}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Document References */}
|
||||
{traceability?.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
|
||||
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-indigo-600" />
|
||||
<h3 className="text-sm font-semibold text-indigo-900">Original-Dokumente ({traceability.document_references.length})</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.document_references.map((dr, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
|
||||
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
|
||||
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
|
||||
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<ExtractionMethodBadge method={dr.extraction_method} />
|
||||
{dr.confidence !== null && <span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Obligations */}
|
||||
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.obligations.map((ob) => (
|
||||
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{ob.normative_strength === 'must' ? 'MUSS' : ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
|
||||
{ob.object && <span className="text-xs text-amber-500">→ {ob.object}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
{ob.obligation_text.slice(0, 300)}{ob.obligation_text.length > 300 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Merged Duplicates */}
|
||||
{traceability?.merged_duplicates && traceability.merged_duplicates.length > 0 && (
|
||||
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.merged_duplicates.map((dup) => (
|
||||
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
|
||||
<ControlLink controlId={dup.control_id} />
|
||||
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
|
||||
{dup.source_regulation && <span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Child controls */}
|
||||
{traceability && traceability.children.length > 0 && (
|
||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-emerald-900">Abgeleitete Controls ({traceability.children.length})</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.children.map((child) => (
|
||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
||||
<ControlLink controlId={child.control_id} />
|
||||
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
||||
<SeverityBadge severity={child.severity} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { CanonicalControl, BACKEND_URL } from './helpers'
|
||||
|
||||
export interface ControlFormData {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface CRUDDeps {
|
||||
selectedControl: CanonicalControl | null
|
||||
fullReload: () => Promise<void>
|
||||
reviewMode: boolean
|
||||
reviewIndex: number
|
||||
reviewItems: CanonicalControl[]
|
||||
setMode: (m: 'list' | 'detail' | 'create' | 'edit') => void
|
||||
setSelectedControl: (c: CanonicalControl | null) => void
|
||||
setReviewMode: (v: boolean) => void
|
||||
setReviewItems: (items: CanonicalControl[]) => void
|
||||
setReviewIndex: (i: number) => void
|
||||
setSaving: (v: boolean) => void
|
||||
setBulkProcessing: (v: boolean) => void
|
||||
reviewCount: number
|
||||
totalCount: number
|
||||
stateFilter: string
|
||||
}
|
||||
|
||||
export function createCRUDHandlers(deps: CRUDDeps) {
|
||||
const {
|
||||
selectedControl, fullReload, reviewMode, reviewIndex, reviewItems,
|
||||
setMode, setSelectedControl, setReviewMode, setReviewItems, setReviewIndex,
|
||||
setSaving, setBulkProcessing, reviewCount, totalCount, stateFilter,
|
||||
} = deps
|
||||
|
||||
const handleCreate = async (data: ControlFormData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=create-control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
|
||||
await fullReload()
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleUpdate = async (data: ControlFormData) => {
|
||||
if (!selectedControl) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=update-control&id=${selectedControl.control_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
|
||||
await fullReload()
|
||||
setSelectedControl(null)
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (controlId: string) => {
|
||||
if (!confirm(`Control ${controlId} wirklich loeschen?`)) return
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?id=${controlId}`, { method: 'DELETE' })
|
||||
if (!res.ok && res.status !== 204) { alert('Fehler beim Loeschen'); return }
|
||||
await fullReload()
|
||||
setSelectedControl(null)
|
||||
setMode('list')
|
||||
} catch { alert('Netzwerkfehler') }
|
||||
}
|
||||
|
||||
const handleReview = async (controlId: string, action: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=review&id=${controlId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fullReload()
|
||||
if (reviewMode) {
|
||||
const remaining = reviewItems.filter(c => c.control_id !== controlId)
|
||||
setReviewItems(remaining)
|
||||
if (remaining.length > 0) {
|
||||
const nextIdx = Math.min(reviewIndex, remaining.length - 1)
|
||||
setReviewIndex(nextIdx)
|
||||
setSelectedControl(remaining[nextIdx])
|
||||
} else { setReviewMode(false); setSelectedControl(null); setMode('list') }
|
||||
} else { setSelectedControl(null); setMode('list') }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleBulkReject = async (sourceState: string) => {
|
||||
const count = stateFilter === sourceState ? totalCount : reviewCount
|
||||
if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return
|
||||
setBulkProcessing(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ release_state: sourceState, action: 'reject' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`)
|
||||
await fullReload()
|
||||
} else { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) }
|
||||
} catch { alert('Netzwerkfehler') } finally { setBulkProcessing(false) }
|
||||
}
|
||||
|
||||
return { handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject }
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { CanonicalControl, Framework, BACKEND_URL } from './helpers'
|
||||
|
||||
export interface ControlsMeta {
|
||||
total: number
|
||||
domains: Array<{ domain: string; count: number }>
|
||||
sources: Array<{ source: string; count: number }>
|
||||
no_source_count: number
|
||||
type_counts?: { rich: number; atomic: number; eigenentwicklung: number }
|
||||
severity_counts?: Record<string, number>
|
||||
verification_method_counts?: Record<string, number>
|
||||
category_counts?: Record<string, number>
|
||||
evidence_type_counts?: Record<string, number>
|
||||
release_state_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useControlLibraryState() {
|
||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [meta, setMeta] = useState<ControlsMeta | null>(null)
|
||||
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
const [stateFilter, setStateFilter] = useState<string>('')
|
||||
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
||||
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||
const [hideDuplicates, setHideDuplicates] = useState(true)
|
||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id')
|
||||
|
||||
// CRUD / UI state
|
||||
const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
const [processedStats, setProcessedStats] = useState<Array<Record<string, unknown>>>([])
|
||||
const [showStats, setShowStats] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false)
|
||||
|
||||
// Review state
|
||||
const [reviewMode, setReviewMode] = useState(false)
|
||||
const [reviewIndex, setReviewIndex] = useState(0)
|
||||
const [reviewItems, setReviewItems] = useState<CanonicalControl[]>([])
|
||||
const [reviewCount, setReviewCount] = useState(0)
|
||||
const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates')
|
||||
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
||||
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
||||
|
||||
// V1 Compare state
|
||||
const [compareMode, setCompareMode] = useState(false)
|
||||
const [compareV1Control, setCompareV1Control] = useState<CanonicalControl | null>(null)
|
||||
const [compareMatches, setCompareMatches] = useState<Array<{
|
||||
matched_control_id: string; matched_title: string; matched_objective: string
|
||||
matched_severity: string; matched_category: string
|
||||
matched_source: string | null; matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number; match_rank: number; match_method: string
|
||||
}>>([])
|
||||
|
||||
const metaAbortRef = useRef<AbortController | null>(null)
|
||||
const controlsAbortRef = useRef<AbortController | null>(null)
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [searchQuery])
|
||||
|
||||
const buildParams = useCallback((extra?: Record<string, string>) => {
|
||||
const p = new URLSearchParams()
|
||||
if (severityFilter) p.set('severity', severityFilter)
|
||||
if (domainFilter) p.set('domain', domainFilter)
|
||||
if (stateFilter) p.set('release_state', stateFilter)
|
||||
if (verificationFilter) p.set('verification_method', verificationFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
||||
if (audienceFilter) p.set('target_audience', audienceFilter)
|
||||
if (sourceFilter) p.set('source', sourceFilter)
|
||||
if (typeFilter) p.set('control_type', typeFilter)
|
||||
if (hideDuplicates) p.set('exclude_duplicates', 'true')
|
||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||
return p.toString()
|
||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
||||
|
||||
const loadFrameworks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
||||
if (res.ok) setFrameworks(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
const loadMeta = useCallback(async () => {
|
||||
if (metaAbortRef.current) metaAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
metaAbortRef.current = controller
|
||||
try {
|
||||
const qs = buildParams()
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||
}
|
||||
}, [buildParams])
|
||||
|
||||
const loadControls = useCallback(async () => {
|
||||
if (controlsAbortRef.current) controlsAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
controlsAbortRef.current = controller
|
||||
try {
|
||||
setLoading(true)
|
||||
const sortField = sortBy === 'id' ? 'control_id' : sortBy === 'source' ? 'source' : 'created_at'
|
||||
const sortOrder = sortBy === 'newest' ? 'desc' : sortBy === 'oldest' ? 'asc' : 'asc'
|
||||
const offset = (currentPage - 1) * PAGE_SIZE
|
||||
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
||||
const countQs = buildParams()
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||
])
|
||||
if (!controller.signal.aborted) {
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
if (countRes.ok) { const data = await countRes.json(); setTotalCount(data.total || 0) }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [buildParams, sortBy, currentPage])
|
||||
|
||||
const loadReviewCount = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
|
||||
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
||||
useEffect(() => { loadMeta() }, [loadMeta])
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
const fullReload = useCallback(async () => {
|
||||
await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
|
||||
}, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
|
||||
|
||||
const loadProcessedStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
||||
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const enterReviewMode = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||
if (res.ok) {
|
||||
const items: CanonicalControl[] = await res.json()
|
||||
if (items.length > 0) {
|
||||
const dupes = items.filter(c => c.generation_metadata?.similar_controls && Array.isArray(c.generation_metadata.similar_controls) && (c.generation_metadata.similar_controls as unknown[]).length > 0)
|
||||
const rule3 = items.filter(c => !c.generation_metadata?.similar_controls || !Array.isArray(c.generation_metadata.similar_controls) || (c.generation_metadata.similar_controls as unknown[]).length === 0)
|
||||
setReviewDuplicates(dupes)
|
||||
setReviewRule3(rule3)
|
||||
const startTab = dupes.length > 0 ? 'duplicates' : 'rule3'
|
||||
const startItems = startTab === 'duplicates' ? dupes : rule3
|
||||
setReviewTab(startTab)
|
||||
setReviewItems(startItems)
|
||||
setReviewMode(true)
|
||||
setReviewIndex(0)
|
||||
setSelectedControl(startItems[0])
|
||||
setMode('detail')
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const switchReviewTab = (tab: 'duplicates' | 'rule3') => {
|
||||
const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3
|
||||
setReviewTab(tab)
|
||||
setReviewItems(items)
|
||||
setReviewIndex(0)
|
||||
if (items.length > 0) setSelectedControl(items[0])
|
||||
}
|
||||
|
||||
return {
|
||||
// Data
|
||||
frameworks, controls, totalCount, meta, selectedControl, setSelectedControl,
|
||||
loading, error,
|
||||
// Filters
|
||||
searchQuery, setSearchQuery, debouncedSearch,
|
||||
severityFilter, setSeverityFilter,
|
||||
domainFilter, setDomainFilter,
|
||||
stateFilter, setStateFilter,
|
||||
verificationFilter, setVerificationFilter,
|
||||
categoryFilter, setCategoryFilter,
|
||||
evidenceTypeFilter, setEvidenceTypeFilter,
|
||||
audienceFilter, setAudienceFilter,
|
||||
sourceFilter, setSourceFilter,
|
||||
typeFilter, setTypeFilter,
|
||||
hideDuplicates, setHideDuplicates,
|
||||
sortBy, setSortBy,
|
||||
// CRUD/UI
|
||||
mode, setMode, saving, setSaving,
|
||||
showGenerator, setShowGenerator,
|
||||
processedStats, showStats, setShowStats,
|
||||
currentPage, setCurrentPage, totalPages,
|
||||
bulkProcessing, setBulkProcessing,
|
||||
// Review
|
||||
reviewMode, setReviewMode,
|
||||
reviewIndex, setReviewIndex,
|
||||
reviewItems, setReviewItems,
|
||||
reviewCount, reviewTab,
|
||||
reviewDuplicates, reviewRule3,
|
||||
// V1 Compare
|
||||
compareMode, setCompareMode,
|
||||
compareV1Control, setCompareV1Control,
|
||||
compareMatches, setCompareMatches,
|
||||
// Actions
|
||||
fullReload, loadControls, loadMeta, loadFrameworks, loadReviewCount,
|
||||
loadProcessedStats, enterReviewMode, switchReviewTab,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user