Extract hooks, sub-components, and constants into colocated files to bring all three page.tsx files under the 500-LOC hard cap (225, 134, 111 LOC). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
233 lines
14 KiB
TypeScript
233 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { Shield, Lock, ListChecks, Trash2, BarChart3, Zap, Plus, RefreshCw, Search, Filter, ArrowUpDown } from 'lucide-react'
|
|
import { Framework } from './helpers'
|
|
import { ControlsMeta } from './types'
|
|
import { VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS } from './helpers'
|
|
|
|
interface ControlsHeaderProps {
|
|
frameworks: Framework[]
|
|
meta: ControlsMeta | null
|
|
reviewCount: number
|
|
loading: boolean
|
|
bulkProcessing: boolean
|
|
showStats: boolean
|
|
processedStats: Array<Record<string, unknown>>
|
|
searchQuery: string
|
|
severityFilter: string
|
|
domainFilter: string
|
|
stateFilter: string
|
|
hideDuplicates: boolean
|
|
verificationFilter: string
|
|
categoryFilter: string
|
|
evidenceTypeFilter: string
|
|
audienceFilter: string
|
|
sourceFilter: string
|
|
typeFilter: string
|
|
sortBy: string
|
|
onSearchChange: (v: string) => void
|
|
onSeverityChange: (v: string) => void
|
|
onDomainChange: (v: string) => void
|
|
onStateChange: (v: string) => void
|
|
onHideDuplicatesChange: (v: boolean) => void
|
|
onVerificationChange: (v: string) => void
|
|
onCategoryChange: (v: string) => void
|
|
onEvidenceTypeChange: (v: string) => void
|
|
onAudienceChange: (v: string) => void
|
|
onSourceChange: (v: string) => void
|
|
onTypeChange: (v: string) => void
|
|
onSortChange: (v: string) => void
|
|
onRefresh: () => void
|
|
onEnterReviewMode: () => void
|
|
onBulkReject: (state: string) => void
|
|
onToggleStats: () => void
|
|
onOpenGenerator: () => void
|
|
onCreateNew: () => void
|
|
}
|
|
|
|
export function ControlsHeader({
|
|
frameworks, meta, reviewCount, loading, bulkProcessing, showStats, processedStats,
|
|
searchQuery, severityFilter, domainFilter, stateFilter, hideDuplicates,
|
|
verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, sortBy,
|
|
onSearchChange, onSeverityChange, onDomainChange, onStateChange, onHideDuplicatesChange,
|
|
onVerificationChange, onCategoryChange, onEvidenceTypeChange, onAudienceChange, onSourceChange, onTypeChange, onSortChange,
|
|
onRefresh, onEnterReviewMode, onBulkReject, onToggleStats, onOpenGenerator, onCreateNew,
|
|
}: ControlsHeaderProps) {
|
|
return (
|
|
<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 ?? 0} Security Controls</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{reviewCount > 0 && (
|
|
<>
|
|
<button onClick={onEnterReviewMode} 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={onToggleStats} 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={onOpenGenerator} 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={onCreateNew} 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>
|
|
)}
|
|
|
|
<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 => onSearchChange(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 => onSeverityChange(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 => onDomainChange(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 => onStateChange(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 => onHideDuplicatesChange(e.target.checked)}
|
|
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
|
Duplikate ausblenden
|
|
</label>
|
|
<select value={verificationFilter} onChange={e => onVerificationChange(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 => onCategoryChange(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 => onEvidenceTypeChange(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 => onAudienceChange(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 => onSourceChange(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 => onTypeChange(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 => onSortChange(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="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>
|
|
)
|
|
}
|