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>
242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
'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,
|
|
}
|
|
}
|