Files
breakpilot-compliance/admin-compliance/app/sdk/control-library/components/ControlListView.tsx
Sharang Parnerkar 083792dfd7 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>
2026-04-17 12:24:58 +02:00

395 lines
23 KiB
TypeScript
Raw Blame History

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